Before you start
-
Plan. Creating an endpoint needs outbound webhooks in the
organization’s plan (Growth and Enterprise). Without it the API answers
402 feature_not_in_plan. -
Scopes.
webhooks:readlists endpoints and deliveries;webhooks:writecreates, changes, deletes and replays. Neither implies the other. -
Test and live are separate. An endpoint registered with a
cust_test_…key belongs to the test environment and receives only test events. Register your production endpoint again with a live key. ThetenantIdin every event tells the two apart. - A public address. Custas only delivers to an address on the public internet; see the URL rules.
1. Register an endpoint
POST /v1/webhook-endpoints
{
"url": "https://shop.example.com/hooks/custas",
"description": "Order system, production",
"eventTypes": ["redemption.committed", "redemption.returned", "redemption.reversed",
"member.updated", "member.merged", "member.deleted", "consent.changed"]
}
# → 201
{
"id": "0199b2c4-…",
"url": "https://shop.example.com/hooks/custas",
"eventTypes": [ … ],
"isActive": true,
"secret": "whsec_kY3…", # shown this once
…
}
-
Store
secretnow. It’s never shown again, and Custas can’t read it back either. Lost means rotated (§9). -
List the events you handle. There’s no wildcard, on purpose: an
event type added to Custas later never starts arriving at a receiver that doesn’t
know it. Add types with
PATCH /v1/webhook-endpoints/{id}. - You can register several endpoints, each with its own events and its own secret, such as one for the order system and one for a data warehouse.
-
There’s no “send a test event” button. To try your receiver, cause a real event in
the test environment: creating a member with a test key sends
member.created.
URL rules
-
https://only. Events carry member data, and the signature only proves who sent an event; it doesn’t hide what’s in it. Plainhttp://is refused when you register (422, fieldurl). -
Every address the hostname resolves to must be public. Private, loopback, link-local
and cloud metadata ranges are refused when you register (
422, fieldurl) and again before every delivery, and the connection is made to the address that was checked. - No user name or password in the URL, and at most 2 000 characters.
-
Redirects are not followed. A
3xxcounts as a failed delivery, so register the final URL.
2. The events
Payloads carry ids, not personal data. There’s no email address,
phone number or personnummer in any of them. When you need more than the payload
gives, fetch the resource by its id. Two exceptions to know about:
code.created and code.assigned carry the discount code
itself, and message.* carries a masked address hint.
| Area | Events |
|---|---|
| Sales |
redemption.reserved, redemption.released,
redemption.failed, redemption.committed,
redemption.returned, redemption.reversed,
redemption.attributed
|
| Members |
member.created, member.updated,
member.merged, member.deleted,
consent.changed
|
| Points and tiers |
points.earned, points.redeemed,
points.adjusted, points.expired,
points.reversed, points.refunded,
tier.changed, tier.at_risk
|
| Stamp cards |
stamps.earned, stamps.rewarded,
stamps.reversed, stamps.adjusted
|
| Gift cards |
gift_card.issued, gift_card.redeemed,
gift_card.refunded, gift_card.topped_up,
gift_card.disabled, gift_card.expiry_extended,
gift_card.balance_removed
|
| Promotions and codes |
promotion.created, promotion.updated,
promotion.status_changed, promotion.started,
promotion.ended, promotion.archived,
code.created, code.assigned,
code.unassigned, code_batch.completed,
code_batch.failed, code_batch.cancelled
|
| Messaging |
message.delivered, message.bounced,
message.complained, broadcast.created,
broadcast.started, broadcast.completed,
broadcast.failed, broadcast.cancelled,
push_campaign.created, push_campaign.started,
push_campaign.completed, push_campaign.cancelled,
automation.created, automation.updated,
automation.deleted
|
| Bulk work |
segment.refreshed, import.completed,
import.failed
|
The ones most integrations need
| Event | Key fields in data |
Typical use |
|---|---|---|
redemption.committed |
reservationId, memberId, externalRef,
redemptions[], tenders[]
|
Book the discounts and tenders against your order |
redemption.returned |
reservationId, refundMinor,
pointsTaken, lines[]
|
Mirror a partial return |
redemption.reversed |
reservationId, reason, reversals[]
|
Mirror a void or full return |
redemption.released |
reservationId, status (released or
expired), reason
|
Notice a hold that lapsed before it was committed |
member.updated |
memberId, status, changedFields[]
|
Refresh a cached member |
member.merged |
memberId (the one kept), mergedMemberId |
Repoint your records from the merged id to the kept one |
member.deleted |
memberId, erased: true,
mergedMemberIds[]
|
Erase the member from your systems too |
consent.changed |
memberId, channel, purpose,
state, source
|
Stop or start marketing in another tool |
points.* |
memberId, delta (signed), balance,
reservationId, externalRef
|
Show the balance without calling back |
tier.changed |
memberId, previousTier, tier,
direction (up or down)
|
Change a customer group in your shop |
gift_card.* |
giftCardId, lastFour, balanceMinor,
status, plus amountMinor and
externalRef where they apply
|
Keep a gift card ledger in step. The code itself is never sent. |
member.deleted is a legal obligation, not a nicety. It
means the member was erased under GDPR. If you copied anything about them, delete it
when this arrives. A member who was merged into another is not deleted; their old id
answers 404 with mergedInto.
3. What a delivery looks like
POST /hooks/custas
content-type: application/json
user-agent: custas-webhooks/1
custas-event-id: 3f6e2a1c-8d4b-4c1e-9a7f-2b5d8e0c4a91
custas-event-type: redemption.committed
custas-timestamp: 1792056251
custas-signature: v1=5d2f8c…a09e
custas-delivery-id: 0199b2c7-…:1
{"id":"3f6e2a1c-8d4b-4c1e-9a7f-2b5d8e0c4a91","type":"redemption.committed","occurredAt":"2026-10-20T10:04:11.482Z","tenantId":"0199a001-…","data":{"reservationId":"0199b2c5-…","memberId":"0199a4f0-…","externalRef":"WEB-100231","redemptions":[…],"tenders":[…]}}
| Header | Meaning |
|---|---|
custas-event-id |
The event’s id, the same as id in the body. The same on every
attempt and every replay: de-duplicate on it.
|
custas-event-type |
The same as type in the body, for routing before parsing |
custas-timestamp |
When this attempt was signed, in Unix seconds |
custas-signature |
v1= and the hex HMAC-SHA256 (§4) |
custas-delivery-id |
Delivery and attempt number. It changes on every attempt, so it’s for your logs, not for de-duplication. |
-
occurredAtis when it happened in Custas, not when it was sent. Use it to order events (§7). - Delivery isn’t instant. Events are sent in a pass that runs every minute, so the first attempt usually arrives within a minute. Don’t build a checkout that waits for a webhook. Use the API’s response for anything the customer is waiting on.
- While an organization is suspended, its deliveries pause, and they resume when it’s reactivated.
4. Verify the signature
Anyone can post to your URL. Before trusting a delivery, check that Custas sent it and that it’s recent:
- Read the raw body bytes, before any JSON parsing. Parsing and re-serialising changes the bytes, and the signature won’t match.
-
Compute HMAC-SHA256 of
<custas-timestamp>.<raw body>, keyed with the whole secret,whsec_prefix included. Hex-encode it and prefix it withv1=. - Compare it with
custas-signaturein constant time. -
Reject a
custas-timestampmore than five minutes from your clock. The timestamp is part of what’s signed, so a captured delivery can’t be replayed later with a new one.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRET = process.env.CUSTAS_WEBHOOK_SECRET; // whsec_…
function verified(rawBody, headers) {
const timestamp = headers['custas-timestamp'] ?? '';
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = Buffer.from(
'v1=' + createHmac('sha256', SECRET).update(`${timestamp}.`).update(rawBody).digest('hex'),
);
const given = Buffer.from(headers['custas-signature'] ?? '');
return given.length === expected.length && timingSafeEqual(given, expected);
}
const app = express();
// express.raw, not express.json: the signature is over the exact bytes.
app.post('/hooks/custas', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verified(req.body, req.headers)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
await queue.add(event); // store it, then answer: see §5
res.sendStatus(204);
});
PHP
$secret = getenv('CUSTAS_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_CUSTAS_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_CUSTAS_SIGNATURE'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401); exit;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401); exit;
}
$event = json_decode($body, true);
// store it, then answer: see §5
http_response_code(204);
A rejected delivery is retried like any other failure. If every delivery suddenly fails verification, check that nothing between Custas and your code (a proxy, a framework body parser) is changing the body, and that you’re using the current secret.
5. Answer quickly, work later
-
Any
2xxwithin 10 seconds is a success. Anything else is a failure: a timeout, another status, a redirect or a dropped connection. -
Verify, store the event (a queue or a table keyed on the event id), answer
2xx, and then do the work. A receiver that calls three other systems before answering times out under load and gets everything again. - The first 2 000 characters of your response body are kept in the delivery log. On a failure, a short reason there makes debugging easier for whoever looks next.
6. Retries, and when an endpoint is switched off
A failed delivery is tried again, nine attempts in all:
| Attempt | After the previous one fails |
|---|---|
| 1 | Within about a minute of the event |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 15 minutes |
| 6 | 1 hour |
| 7 | 3 hours |
| 8 | 6 hours |
| 9 | 12 hours |
These are the earliest times. Deliveries go out in a pass that runs every minute, so each attempt can come up to a minute later, and the 10-second retry in practice arrives with the next pass.
After the ninth failure, a little over 22 hours after the first attempt, the delivery
is
abandoned. It stays in the delivery log, and you can replay it (§8).
Twenty failures in a row switch the endpoint off
The count is per endpoint, across all its deliveries, and any success resets it. At
twenty the endpoint is deactivated, and disabledAt and
disabledReason on it say why.
While an endpoint is off, new events are not queued for it at all.
Turning it back on doesn’t bring them back. Reactivate it with
PATCH /v1/webhook-endpoints/{id} and { "isActive": true },
then reconcile the gap from the API. For example, GET /v1/redemptions
lists sales newest first; page back until you reach the outage. Deliveries that were
already queued before it was switched off resume on their own.
Watch consecutiveFailures and lastError on
GET /v1/webhook-endpoints, or the Webhooks page in the portal, and alert
on them before they reach twenty.
7. Duplicates and order
Every event can arrive more than once
Delivery is at least once. A timeout after your code had already
processed the event, a retry, or a replay all send it again, with the same
custas-event-id. Record the ids you have processed, and skip one you’ve
seen:
INSERT INTO custas_events (event_id, received_at) VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING; -- 0 rows: already handled, answer 2xx and stop
Do it in the same transaction as the work, so a crash can’t record the id without the work or the work without the id. Keep the ids for at least a few days: replays come from the delivery log, which goes back 30 days.
Events can arrive out of order
Deliveries are sent concurrently and retried independently, so
points.earned for Monday’s purchase can arrive after Tuesday’s. Never
assume the last event to arrive is the latest state:
-
Compare
occurredAtwith what you stored, and ignore an older event for a resource you have newer data on. -
Or treat the event as a prompt: on
member.updated, fetch the member and store what the API returns. The API always answers with the current state, whatever order the events came in. -
Point and gift card events carry the resulting
balanceorbalanceMinor. Keep the one with the latestoccurredAtinstead of adding updeltas: a missed or doubled event then can’t leave you wrong for good.
8. The delivery log, and replaying
GET /v1/webhook-deliveries?endpointId={id}&status=abandoned
POST /v1/webhook-deliveries/{deliveryId}/replay # → 202 { "status": "pending" }
-
Each delivery shows its
eventId,eventType,status(pending,delivering,delivered,failedorabandoned),attemptCount, and your lastresponseStatus,responseBodyandlastError. - Replay sends the same event, with the same id, and gives it a fresh nine attempts. It works on a delivered event too, which is useful after you fix a bug in how you handled it. Your de-duplication will skip it unless you clear that id first.
- A delivery that’s being attempted right now answers
409. - Events are kept for 30 days, longer if a delivery is still waiting for a retry. After that, reconcile from the API instead.
- The portal’s Webhooks page shows the same log, with a replay button.
9. Rotating the secret
PATCH /v1/webhook-endpoints/{id} { "rotateSecret": true } # → 200, with the new "secret" once
The new secret signs every delivery from that moment, and the old one stops working at once: there’s no overlap period, and a delivery carries one signature.
- Rotate, and put the new secret in your secret store straight away.
- Deliveries that arrive before your receiver has the new secret fail verification. That’s harmless: they’re retried within a minute, again a minute after that, and pass once the receiver has the new secret.
- If your receiver runs on several servers that pick up configuration at different times, let each one accept a signature from either the old or the new secret during the switch, then remove the old one.
Rotate whenever the secret may have been exposed: in a log, a screenshot, or a repository. A lost secret can only be rotated, never read back.
10. Checklist
- Separate endpoints registered for test and live
- Only the event types you handle are subscribed
- The secret is in a secret store, never in code or logs
- The signature is verified on the raw body, in constant time
- Timestamps more than five minutes old are rejected
- Events are stored and answered within 10 seconds; the work happens after
- Processed event ids are recorded in the same transaction as the work
- Order comes from
occurredAtor a fresh read, never arrival order member.deletederases the member from your systemsmember.mergedrepoints your records to the kept member- An alert fires on
consecutiveFailureswell before twenty - A plan for reconciling from the API after an outage
- Secret rotation rehearsed in the test environment