Skip to Content

Webhooks

Webhooks are still a release candidate, so the payloads and the subscription API can still change. We record every change in the changelog.

You create and manage subscriptions with the Webhooks API.

When you subscribe to events, we POST each event to the callback URL of your subscription. We sign every one of those requests, and include the signature in an X-HMAC-Signature header.

This page first explains how we deliver events and what we do when a delivery fails. After that, it explains how to verify the signature.

How We Deliver an Event

We send each event as one HTTP POST request with a JSON body. Your endpoint must answer with a status code from 200 to 299, and it must answer within 10 seconds.

Delivery is at-least-once. This means that we deliver every event one or more times, so you must expect duplicates. For example, if your endpoint processes an event but answers after 10 seconds, we count the delivery as failed and send the event again. For this reason, we recommend that your endpoint stores the event, answers at once, and does the processing afterwards.

We do not promise an order. When a delivery fails, we try it again later, and in the meantime we deliver later events as usual. So an event can arrive after events that happened after it. Use eventTime and the data of the event to decide the order, not the time of arrival.

What Counts as a Failed Delivery

A delivery fails when one of these things happens:

  • Your endpoint answers with a status code outside 200 to 299.
  • We cannot connect to your endpoint, for example because of a Domain Name System (DNS) error or a Transport Layer Security (TLS) error.
  • Your endpoint does not answer within 10 seconds.

Each attempt to deliver an event is in fact 3 tries, 1 second apart. Only when all 3 tries fail do we count the attempt as failed, and we schedule the next attempt as shown below.

Retry Schedule in Production

When the first attempt fails, we try again on the schedule below. The times are measured from the moment the first attempt failed, which is normally a few seconds after the event happened.

AttemptWait before the attemptTime since the first failure
15 minutes5 minutes
210 minutes15 minutes
315 minutes30 minutes
430 minutes1 hour
545 minutes1 hour 45 minutes
61 hour2 hours 45 minutes
76 hours8 hours 45 minutes
812 hours20 hours 45 minutes
924 hours1 day 20 hours 45 minutes
1024 hours2 days 20 hours 45 minutes
1124 hours3 days 20 hours 45 minutes
1224 hours4 days 20 hours 45 minutes
1324 hours5 days 20 hours 45 minutes
1424 hours6 days 20 hours 45 minutes
1524 hours7 days 20 hours 45 minutes

Attempt 15 is the last one. If it fails, we give up on the event.

The times are approximate. Each wait starts when the previous attempt ends, so every attempt adds a few seconds, and up to about 30 seconds when your endpoint does not answer.

To plan around this schedule, use this rule: if your endpoint works again within 7 days of the first failure, you receive the event. Do not plan on the extra hours after 7 days.

Retry Schedule in the Sandbox

The sandbox, at https://api.banking-services-sandbox.lunar.app, uses a much shorter schedule:

Wait before each attemptGive up after
1 minuteabout 15 minutes

This is not the production schedule. In production we keep trying for 7 days, in the sandbox only for about 15 minutes. So a failure test in the sandbox does not show what happens in production. Use the sandbox to test that your endpoint handles a retry correctly, and use the production table above to plan your operations.

When We Give Up

When the last attempt fails, we discard the event. We do not keep it, and we do not tell you.

There is no API to replay or redeliver a discarded event. Apart from deleting the subscription, see Deleting a Subscription, this is the one case in which you lose an event: your endpoint fails for longer than the retry schedule, which is 7 days in production.

To protect yourself, we recommend two things:

  • Monitor your callback endpoint, so you notice an outage long before 7 days pass.
  • Do not rely on webhooks alone for data you must not miss. Reconcile with our APIs as well, for example with the Postings API, as the Reconciliation page describes.

Deleting a Subscription

When you delete a subscription, we stop delivering to it at once. That includes the retries we have already scheduled for earlier events: we discard those events, and we do not deliver them later. So you cannot use a delete to pause delivery and resume it.

You cannot reuse the {id} of a deleted subscription. Creating a subscription with that {id} again answers 409 CONFLICT, and updating it answers 404 NOT_FOUND. To subscribe again, create a subscription with a new {id}.

Duplicates and the Idempotency Key

Every delivery has an idempotencyKey. It is a Universally Unique Identifier (UUID) in the canonical form, for example b22bd63b-5c60-4417-9e0e-8df07ec7fe0d. The value stays the same for every retry of the same event. Record the keys you have processed, and ignore a delivery with a key you have already seen.

The key identifies the event, not the subscription. When one event matches two of your subscriptions, we deliver it once to each subscription, and both deliveries have the same idempotencyKey.

So which value you deduplicate on depends on how you set up your callback URLs:

  • If each subscription has its own callback URL, the idempotencyKey alone is enough.
  • If several subscriptions share one callback URL, deduplicate on the pair (subscriptionId, idempotencyKey). If you use the key alone, you may treat the delivery for the second subscription as a duplicate of the first one.

Do Not Put a Redirect in Front of the Callback URL

We follow HTTP redirects from your callback URL. But a redirect with the status code 301, 302 or 303 makes us send a GET request without a body to the new location, instead of the POST. The event and its signature are then lost. If the new location answers the GET with a 2xx status code, we count the delivery as successful, so we do not retry it.

Register the final URL of your endpoint as the callback URL, so that it answers the POST directly.

How We Sign a Delivery

We use a Hash-based Message Authentication Code (HMAC) with the Secure Hash Algorithm 256 (SHA-256).

PartWhat we use
AlgorithmHMAC-SHA256
Secretthe subscriptionKey of the subscription that produced the event
Messagethe exact bytes of the request body, as we sent them
Encoding64 lower case hexadecimal characters, with no prefix and no version tag
HeaderX-HMAC-Signature

To verify a delivery, compute the same value yourself and compare it with the header.

Where to Find Your Subscription Key

The secret is the subscriptionKey field of your subscription. You can read it in three places, all of which use your ordinary OAuth2 credentials:

  • POST /v1/webhook-subscriptions/{id}, the response that created the subscription. You choose the {id}.
  • GET /v1/webhook-subscriptions/{id}, for one subscription.
  • GET /v1/webhook-subscriptions, for all of your subscriptions.

Each subscription has its own key. There is no single key for your organisation, so if you have three subscriptions you have three keys.

The payload names its own subscription in the subscriptionId field, and that value is the id you chose when you created the subscription. It is not the internalId we return next to it. Use subscriptionId to look up the key you should verify with.

What the Key Looks Like

A subscriptionKey is 32 characters long and contains only the letters A to Z and a to z.

Use the bytes of that string exactly as we gave them to you. It is a common mistake to assume a signing secret is hexadecimal or base64 and to decode it first. This key is neither, so decoding it produces the wrong secret and every verification fails.

A Worked Example

You can check your implementation against these values before you receive a single real delivery. The event below is a posting, because it has to be something, but nothing in the computation depends on the event type. The same steps verify a transfer event or any other event you subscribe to.

The subscription key:

aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeF

The request body, which is 348 bytes on one line, with no trailing newline:

{"subscriptionId":"sub_12345","idempotencyKey":"b22bd63b-5c60-4417-9e0e-8df07ec7fe0d","eventTime":"2026-02-24T10:30:00Z","eventType":"Posting.CreatedV1","data":{"accountNumber":"45588281470610","ledgerPostingId":"b22bd63b-5c60-4417-9e0e-8df07ec7fe0d","amount":{"value":"41.00","currency":"DKK","credit":false},"postingTime":"2026-02-24T10:30:00Z"}}

The header we would send with it:

X-HMAC-Signature: eb36f39a288aa2a35d8409ca0763deff9371d2eb88c9c34862110b0c1fc48778

Use this as a test vector and nothing more. In particular, do not read the order of the fields as a promise. The order above is what we send today, and it is stable enough to build a test with, but we may change it. Your code stays correct through such a change only because it verifies the bytes it received, rather than bytes it rebuilt itself.

Verifying a Delivery in Go

package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" ) // keyFor returns the subscriptionKey of one of your subscriptions. Read the keys from the // Webhooks API once and keep them in your own secret store. func keyFor(subscriptionID string) (string, bool) { keys := map[string]string{ "sub_12345": "aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeF", } key, ok := keys[subscriptionID] return key, ok } // verify reports whether signature is the value we would have sent for body. func verify(body []byte, key, signature string) bool { received, err := hex.DecodeString(signature) if err != nil { return false } mac := hmac.New(sha256.New, []byte(key)) mac.Write(body) // hmac.Equal takes the same time whether the values match or not, so it does not // leak the expected signature through how long the comparison runs. return hmac.Equal(received, mac.Sum(nil)) } func handleCallback(w http.ResponseWriter, r *http.Request) { // Read the body first and keep these bytes. The signature covers exactly what we // sent, so verify against them and never against a re-encoding of them. body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "cannot read body", http.StatusBadRequest) return } // Parsing the body to find out which subscription this is is fine. Verifying // against the result of that parse is not. var envelope struct { SubscriptionID string `json:"subscriptionId"` } if err := json.Unmarshal(body, &envelope); err != nil { http.Error(w, "cannot parse body", http.StatusBadRequest) return } key, ok := keyFor(envelope.SubscriptionID) if !ok { http.Error(w, "unknown subscription", http.StatusBadRequest) return } if !verify(body, key, r.Header.Get("X-HMAC-Signature")) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } // The delivery is authentic. Handle the event, bearing in mind that you may have // seen it before. w.WriteHeader(http.StatusOK) } func main() { http.HandleFunc("/callbacks/lunar", handleCallback) log.Fatal(http.ListenAndServe(":8080", nil)) }

To check your setup against the worked example above, without waiting for a delivery:

package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" ) func main() { key := "aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeF" body := `{"subscriptionId":"sub_12345","idempotencyKey":"b22bd63b-5c60-4417-9e0e-8df07ec7fe0d","eventTime":"2026-02-24T10:30:00Z","eventType":"Posting.CreatedV1","data":{"accountNumber":"45588281470610","ledgerPostingId":"b22bd63b-5c60-4417-9e0e-8df07ec7fe0d","amount":{"value":"41.00","currency":"DKK","credit":false},"postingTime":"2026-02-24T10:30:00Z"}}` mac := hmac.New(sha256.New, []byte(key)) mac.Write([]byte(body)) fmt.Println(hex.EncodeToString(mac.Sum(nil))) // eb36f39a288aa2a35d8409ca0763deff9371d2eb88c9c34862110b0c1fc48778 }

Three Mistakes to Avoid

Verifying re-serialised JSON. Parse the body if you need to, but always compute the signature over the bytes you received. If you parse the JSON and encode it again, the whitespace, the number formatting or the field order can differ from what we sent, and the signature will not match even though the delivery was genuine. Many web frameworks parse the body for you and discard the original bytes, so check how to reach the raw body in yours before you start.

Using the wrong key. The key belongs to one subscription. If several of your subscriptions point at the same callback URL, a request signed with the key of subscription A will not verify with the key of subscription B. Read subscriptionId from the payload and select the key by it.

Comparing signatures with an ordinary string comparison. Most comparisons stop at the first byte that differs, so how long they take reveals how much of the value was correct. That is enough to let somebody guess a valid signature one byte at a time. Use a constant time comparison instead, such as hmac.Equal in Go or hmac.compare_digest in Python.

The Signature Proves Authenticity, Not Freshness

We sign the body and nothing else. There is no timestamp and no nonce in what we sign, so a signature stays valid for as long as the key does. Anyone who captures a delivery can send you the same bytes again, and they will verify correctly, because they are a genuine delivery.

The signature therefore tells you that we produced this event, not that we produced it just now, and not that you are seeing it for the first time.

Protect yourself with idempotency rather than with the signature. We deliver at-least-once, which means you should expect the same event more than once even when nobody is replaying anything. Every delivery carries an idempotencyKey, and its value stays the same across our own retries of that event. Record the keys you have processed and ignore an event whose key you have already seen. If several subscriptions share one callback URL, use the pair (subscriptionId, idempotencyKey) instead, as Duplicates and the Idempotency Key explains.

If a Key Is Leaked

There is no way to rotate a subscriptionKey today. PUT /v1/webhook-subscriptions/{id} changes the callback URL and the event types, and it leaves the key alone.

The workaround is to replace the subscription:

  1. Create a new subscription, under a new id, with the same callback URL and event types. Its response contains a new subscriptionKey.
  2. Accept both keys in your callback for as long as it takes for events in flight to arrive. Select the key by subscriptionId, as above.
  3. Delete the old subscription with DELETE /v1/webhook-subscriptions/{id}. The delete discards any retries still pending for the old subscription, see Deleting a Subscription.

Step 1 has to use a new id. Creating a subscription again under the id of a deleted one answers 409 CONFLICT.

Expect events to be duplicated across the two subscriptions while both are active.

Last updated on