Skip to content
Developers

Webhooks, from first event to the last retry.

Custas tells your system when something happens: a sale is committed, a member signs up, points are earned, a gift card is spent. This guide covers receiving those events safely, and staying correct when deliveries arrive twice, late or out of order.

The reference is the authority on every field. Examples are trimmed to what matters for the step.

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:read lists endpoints and deliveries; webhooks:write creates, 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. The tenantId in 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 secret now. 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. Plain http:// is refused when you register (422, field url).
  • Every address the hostname resolves to must be public. Private, loopback, link-local and cloud metadata ranges are refused when you register (422, field url) 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 3xx counts as a failed delivery, so register the final URL.
Reference: webhook endpoints

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.
  • occurredAt is 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:

  1. Read the raw body bytes, before any JSON parsing. Parsing and re-serialising changes the bytes, and the signature won’t match.
  2. Compute HMAC-SHA256 of <custas-timestamp>.<raw body>, keyed with the whole secret, whsec_ prefix included. Hex-encode it and prefix it with v1=.
  3. Compare it with custas-signature in constant time.
  4. Reject a custas-timestamp more 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 2xx within 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 occurredAt with 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 balance or balanceMinor. Keep the one with the latest occurredAt instead of adding up deltas: 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, failed or abandoned), attemptCount, and your last responseStatus, responseBody and lastError.
  • 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.

  1. Rotate, and put the new secret in your secret store straight away.
  2. 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.
  3. 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 occurredAt or a fresh read, never arrival order
  • member.deleted erases the member from your systems
  • member.merged repoints your records to the kept member
  • An alert fires on consecutiveFailures well before twenty
  • A plan for reconciling from the API after an outage
  • Secret rotation rehearsed in the test environment