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.

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 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}.

Step 1 has to use a new id. Deleting a subscription and creating it again under the same id won’t work.

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

Last updated on