Guides
Receiving webhooks
Receive events on your server and verify that Abuna sent them.
Abuna sends an HTTPS POST to your server when something happens in your app, like a paid invoice or a canceled subscription. Each request is signed with your webhook signing secret, so you can check that it came from Abuna.
Add an endpoint
In the dashboard, open Webhooks and set the Endpoint URL. Only a team owner can change it, and only from the dashboard. A secret key can't repoint your webhooks.
Test mode and Live mode each have their own endpoint and signing secret. A new Test app doesn't copy the Live endpoint, so Test events never reach a server meant for real customers.
The endpoint URL must:
- Use
https. - Have a host, and no username or password in it.
- Not point at
localhost, or at a private, loopback, link-local, or carrier-grade NAT address. Abuna checks the address it connects to on every request and every redirect, not only the URL you save.
To receive events on your own machine, expose it through a public HTTPS tunnel and use that URL.
Get your signing secret
Abuna shows each signing secret once: when you create the app, and when you rotate the secret. A Test secret looks like whsec_test_... and a Live one like whsec_live_.... Store it on your server, next to your secret key.
What Abuna sends
Each delivery is a JSON body with these headers:
X-Abuna-Event: the event type, likeinvoice.paid.X-Abuna-Delivery: the ID of this delivery. Retries of a delivery keep it. A redelivery gets a new one.X-Abuna-Environment:testorlive.X-Abuna-Signature:sha256=and the hex HMAC-SHA256 of the raw body, keyed by your signing secret.X-Abuna-Signature-Previous: the same signature made with your old secret. It's sent only in the 24 hours after you rotate the secret.
The body always has the same five fields. id is the event's ID. data depends on the type. The mode is in the body as well as the header, because only the body is signed.
{
"created_at": 1791005801,
"data": {
"kind": "period",
"amount": 5000,
"credit": 0,
"paid_at": 1791005801,
"currency": "XAF",
"entry_id": "01M4047ETRRSRY6EY5FH0X9XCG",
"metadata": {
"user_id": "42"
},
"invoice_id": "01M4047ETS17EJ5ZR31PWQBWF2",
"period_end": 1793684201,
"period_start": 1791005801,
"invoice_number": "01M4047ETSAJKDQ118H6TN7XG0",
"subscription_id": "01M4047ETPMMZ6EWNJQBD1S7W6"
},
"environment": "test",
"id": "01M4047EV16DJZJQKM0ADM4T99",
"type": "invoice.paid"
}Verify the signature
For every request:
- Read the raw body, as bytes, before you parse it.
- Compute the HMAC-SHA256 of those bytes with your signing secret, hex-encode it, and put
sha256=in front. - Compare the result with
X-Abuna-Signature, and withX-Abuna-Signature-Previousif it's there. Use a constant-time comparison. - If neither matches, answer
400and ignore the event.
Checking both headers keeps your endpoint working while you rotate the secret. Whichever secret your server holds, one of the two signatures matches it.
import crypto from "node:crypto";
import http from "node:http";
function verifyAbunaSignature(rawBody, headers, secret) {
const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"));
return ["x-abuna-signature", "x-abuna-signature-previous"].some((name) => {
const received = Buffer.from(headers[name] ?? "");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
http
.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
if (!verifyAbunaSignature(rawBody, req.headers, process.env.ABUNA_WEBHOOK_SECRET)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody);
console.log("Received", event.type, event.id);
res.writeHead(200).end();
});
})
.listen(3000);Respond with a 2xx
A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Abuna follows up to 4 redirects, and checks each one against the URL rules above. Any other answer, a timeout, or a connection error is a failed attempt.
Abuna tries a delivery up to 10 times. After a failed attempt it waits about 1, 3, 9, 27, 81, and 243 minutes, then 6 hours between each of the last attempts, give or take 10%. The attempts cover about a day. After the 10th failed attempt, the delivery is marked failed and isn't retried.
To handle events safely:
- Answer first, then do slow work in the background, so you stay inside the 10 seconds.
- Record each event's
idand skip one you've already handled. Retries and redeliveries carry the same event. - Don't rely on order. Abuna sends deliveries in parallel, and a retry can arrive after a newer event.
Event types
session.completed: a customer finished a session.data.typesays which: forcheckout, the first payment went through anddata.subscription_idis the new subscription.data.metadatais what you passed when you created it.session.expired: a session ran out or you expired it, without completing.subscription.created: a subscription started and is waiting for its first payment.subscription.paid: the first payment went through and the subscription is active.subscription.renewed: a new billing period started.subscription.updated: a cancel at the end of the paid period was scheduled or undone, the price changed, a downgrade was scheduled or undone, or an upgrade waiting for payment lapsed.data.cancel_atis when it ends, ornull.data.price_idis the current price, anddata.changesays what changed about the price.data.updated_byismerchantorcustomer.plan_offer.created,plan_offer.confirmed,plan_offer.canceled,plan_offer.expired: a plan change waiting for the customer was created, accepted, withdrawn or replaced, or ran out.subscription.canceled: the subscription ended, including at the end of the paid period.data.reasonismerchant,customer,non_payment, orcheckout_expired.invoice.created: an invoice was issued for a billing period.data.kindisplan_changefor the first period of an upgrade.invoice.paid: an invoice was paid.invoice.payment_failed: a payment on an invoice didn't go through.price_change.scheduled,price_change.canceled,price_change.applied: a new amount for a price was scheduled, canceled or replaced, or took effect on its day.data.reasononprice_change.canceledismerchantorreplaced.customer.updated: a customer's name, email, or phone number changed.data.updated_byismerchantorcustomer.
Subscription, invoice, and plan offer events carry the subscription's metadata. Session events carry the session's own metadata, not the subscription's. Pass metadata on every session you create so its events can identify your user. Without it, session events carry {}.
Every event is also kept in your app's event list, even when no endpoint is set. See Events for each type's data.
Send a test event
To check your endpoint, click Send test event in the dashboard, or call POST/v1/apps/{appID}/webhooks/test. Abuna sends one signed ping event right away and tells you how your endpoint answered. It's tried once, and it's not saved as an event or a delivery.
curl -X POST https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/webhooks/test \
-H "Authorization: Bearer sk_test_..."{
"delivered": true,
"response_status": 200,
"error": null
}delivered is true when your endpoint answered 2xx. If no answer came back, response_status is null and error says why. You can send 10 test events per minute. Without an endpoint, the call returns 422 with code webhook_url_missing.
{
"id": "01J9ZQ8E1F2G3H4J5K6M7N8P9Q",
"type": "ping",
"environment": "test",
"created_at": 1727600000,
"data": {
"message": "A test event sent from the Abuna dashboard."
}
}Redeliver failed events
Once your endpoint is fixed, list failed deliveries with GET/v1/apps/{appID}/webhooks?status=failed, then redeliver each one with POST/v1/apps/{appID}/webhooks/{id}/redeliver. A redelivery sends the same body again, as a new delivery, to your current endpoint URL. It's signed with your current secret and retried like any other.
curl "https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/webhooks?status=failed" \
-H "Authorization: Bearer sk_test_..."
curl -X POST https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/webhooks/01J9ZQ8D1E2F3G4H5J6K7M8N9P/redeliver \
-H "Authorization: Bearer sk_test_..."See Webhook deliveries for the fields on each delivery.
Rotate the signing secret
If your secret leaks, a team owner can click Rotate secret on the Webhooks page. You can't rotate it with a secret key. Abuna shows the new secret once and signs with it right away.
For the next 24 hours, each request also carries X-Abuna-Signature-Previous, signed with the old secret. Put the new secret on your server within that time. If you rotate again inside the 24 hours, the secret you just replaced becomes the previous one, and the one before it stops working.