Innan du börjar
-
Plan. För att skapa en endpoint måste organisationens plan
innehålla utgående webhooks (Growth och Enterprise). Annars svarar API:et
402 feature_not_in_plan. -
Scopes.
webhooks:readlistar endpoints och leveranser;webhooks:writeskapar, ändrar, raderar och skickar om. Det ena ger inte automatiskt det andra. -
Test och skarp drift är åtskilda. En endpoint som registreras med
en
cust_test_…-nyckel hör till testmiljön och tar bara emot testhändelser. Registrera din produktions-endpoint igen med en skarp nyckel. MedtenantIdi varje händelse kan du skilja dem åt. - En publik adress. Custas levererar bara till adresser på det publika internet; se reglerna för URL:er.
1. Registrera en 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…", # visas bara den här gången
…
}
-
Spara
secretdirekt. Den visas aldrig igen, och Custas kan inte heller läsa ut den i efterhand. Tappar du den måste du byta den (§9). -
Ange de händelser du hanterar. Det finns medvetet inget
jokertecken: en händelsetyp som läggs till i Custas senare börjar aldrig dyka upp
hos en mottagare som inte känner till den. Lägg till typer med
PATCH /v1/webhook-endpoints/{id}. - Du kan registrera flera endpoints, var och en med egna händelser och egen hemlighet, till exempel en för ordersystemet och en för ett datalager.
-
Det finns ingen knapp för att skicka en testhändelse. Vill du prova din mottagare
skapar du en riktig händelse i testmiljön: när du skapar en medlem med en testnyckel
skickas
member.created.
Regler för URL:er
-
Endast
https://. Händelserna innehåller medlemsdata, och signaturen bevisar bara vem som skickade en händelse; den döljer inte vad den innehåller. Vanlighttp://nekas vid registrering (422, fälturl). -
Alla adresser som värdnamnet pekar på måste vara publika. Privata adresser,
loopback, link-local och molnets metadata-intervall nekas när du registrerar
(
422, fälteturl) och igen före varje leverans, och anslutningen görs till just den adress som kontrollerades. - Inget användarnamn eller lösenord i URL:en, och högst 2 000 tecken.
-
Omdirigeringar följs inte. Ett
3xxräknas som en misslyckad leverans, så registrera den slutliga URL:en.
2. Händelserna
Payloads innehåller id:n, inte personuppgifter. Ingen av dem
innehåller e-postadress, telefonnummer eller personnummer. Behöver du mer än payloaden
ger hämtar du resursen med dess id. Två undantag att känna till:
code.created och code.assigned innehåller själva
rabattkoden, och message.* innehåller en maskerad adressledtråd.
| Område | Händelser |
|---|---|
| Försäljning |
redemption.reserved, redemption.released,
redemption.failed, redemption.committed,
redemption.returned, redemption.reversed,
redemption.attributed
|
| Medlemmar |
member.created, member.updated,
member.merged, member.deleted,
consent.changed
|
| Poäng och nivåer |
points.earned, points.redeemed,
points.adjusted, points.expired,
points.reversed, points.refunded,
tier.changed, tier.at_risk
|
| Stämpelkort |
stamps.earned, stamps.rewarded,
stamps.reversed, stamps.adjusted
|
| Presentkort |
gift_card.issued, gift_card.redeemed,
gift_card.refunded, gift_card.topped_up,
gift_card.disabled, gift_card.expiry_extended,
gift_card.balance_removed
|
| Kampanjer och koder |
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
|
| Meddelanden |
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
|
| Massjobb |
segment.refreshed, import.completed,
import.failed
|
De som de flesta integrationer behöver
| Händelse | Viktiga fält i data |
Typisk användning |
|---|---|---|
redemption.committed |
reservationId, memberId, externalRef,
redemptions[], tenders[]
|
Bokför rabatter och betalmedel mot din order |
redemption.returned |
reservationId, refundMinor,
pointsTaken, lines[]
|
Spegla en delretur |
redemption.reversed |
reservationId, reason, reversals[]
|
Spegla en makulering eller hel retur |
redemption.released |
reservationId, status (released eller
expired), reason
|
Upptäck en reservation som löpte ut innan den genomfördes |
member.updated |
memberId, status, changedFields[]
|
Uppdatera en cachad medlem |
member.merged |
memberId (den som behålls), mergedMemberId |
Peka om dina poster från det sammanslagna id:t till det som behålls |
member.deleted |
memberId, erased: true,
mergedMemberIds[]
|
Radera medlemmen i dina system också |
consent.changed |
memberId, channel, purpose,
state, source
|
Stoppa eller starta marknadsföring i ett annat verktyg |
points.* |
memberId, delta (med tecken), balance,
reservationId, externalRef
|
Visa saldot utan att anropa API:et |
tier.changed |
memberId, previousTier, tier,
direction (up eller down)
|
Byt kundgrupp i din webbshop |
gift_card.* |
giftCardId, lastFour, balanceMinor,
status, samt amountMinor och
externalRef där de är relevanta
|
Håll en presentkortsreskontra i takt. Själva koden skickas aldrig. |
member.deleted är en rättslig skyldighet, inte en artighet.
Den betyder att medlemmen har raderats enligt GDPR. Om du har kopierat något om
medlemmen ska du radera det när händelsen kommer. En medlem som har slagits samman med
en annan är inte raderad; det gamla id:t svarar 404 med
mergedInto.
3. Så ser en leverans ut
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 | Betydelse |
|---|---|
custas-event-id |
Händelsens id, samma som id i bodyn. Det är detsamma vid varje
försök och varje omsändning: filtrera dubbletter på det.
|
custas-event-type |
Samma som type i bodyn, för routning innan du parsar |
custas-timestamp |
När just det här försöket signerades, i Unix-sekunder |
custas-signature |
v1= följt av HMAC-SHA256 i hex (§4) |
custas-delivery-id |
Leverans och försöksnummer. Det ändras vid varje försök, så det är till för dina loggar, inte för att filtrera dubbletter. |
-
occurredAtär när det hände i Custas, inte när det skickades. Använd det för att ordna händelser (§7). - Leveransen är inte omedelbar. Händelser skickas i en körning varje minut, så det första försöket kommer oftast inom en minut. Bygg inte en kassa som väntar på en webhook. Använd API:ets svar för allt som kunden väntar på.
- Medan en organisation är avstängd pausas dess leveranser, och de återupptas när den aktiveras igen.
4. Verifiera signaturen
Vem som helst kan skicka till din URL. Innan du litar på en leverans ska du kontrollera att Custas skickade den och att den är färsk:
- Läs bodyns råa byte innan någon JSON-parsning. Att parsa och serialisera om ändrar byten, och då stämmer inte signaturen.
-
Beräkna HMAC-SHA256 av
<custas-timestamp>.<raw body>med hela hemligheten som nyckel, inklusive prefixetwhsec_. Hex-koda resultatet och sättv1=framför. - Jämför med
custas-signaturei konstant tid. -
Neka en
custas-timestampsom skiljer sig mer än fem minuter från din klocka. Tidsstämpeln ingår i det som signeras, så en uppsnappad leverans kan inte skickas igen senare med en ny.
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, inte express.json: signaturen gäller exakt de byte som skickades.
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); // spara den, svara sedan: se §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);
// spara den, svara sedan: se §5
http_response_code(204);
En nekad leverans får nya försök precis som andra fel. Om plötsligt alla leveranser misslyckas med verifieringen: kontrollera att inget mellan Custas och din kod (en proxy, ett ramverks body-parser) ändrar bodyn, och att du använder den aktuella hemligheten.
5. Svara snabbt, jobba sen
-
Vilket
2xxsom helst inom 10 sekunder räknas som lyckat. Allt annat är ett fel: en timeout, en annan status, en omdirigering eller en bruten anslutning. -
Verifiera, spara händelsen (i en kö eller en tabell med händelsens id som nyckel),
svara
2xxoch gör sedan jobbet. En mottagare som anropar tre andra system innan den svarar får timeout under hög last och får allt en gång till. - De första 2 000 tecknen i bodyn i ditt svar sparas i leveransloggen. Vid fel gör en kort förklaring där felsökningen lättare för den som tittar härnäst.
6. Nya försök, och när en endpoint stängs av
En misslyckad leverans försöks igen, totalt nio försök:
| Försök | Efter att det föregående misslyckats |
|---|---|
| 1 | Inom ungefär en minut efter händelsen |
| 2 | 10 sekunder |
| 3 | 1 minut |
| 4 | 5 minuter |
| 5 | 15 minuter |
| 6 | 1 timme |
| 7 | 3 timmar |
| 8 | 6 timmar |
| 9 | 12 timmar |
Det här är de tidigaste tiderna. Leveranser skickas i en körning varje minut, så varje försök kan komma upp till en minut senare, och i praktiken kommer det nya försöket efter 10 sekunder med nästa körning.
Efter det nionde misslyckandet, drygt 22 timmar efter det första försöket, får
leveransen status
abandoned. Den finns kvar i leveransloggen, och du kan skicka om den (§8).
Tjugo misslyckanden i rad stänger av endpointen
Räkningen sker per endpoint, över alla dess leveranser, och varje lyckad leverans
nollställer den. Vid tjugo inaktiveras endpointen, och disabledAt och
disabledReason på den anger varför.
Medan en endpoint är avstängd köas inga nya händelser alls för den.
De kommer inte tillbaka när du slår på den igen. Aktivera den med
PATCH /v1/webhook-endpoints/{id} och { "isActive": true },
och stäm sedan av glappet via API:et. Till exempel listar
GET /v1/redemptions
köp med det senaste först; bläddra bakåt tills du når avbrottet. Leveranser som redan
låg i kö innan den stängdes av återupptas av sig själva.
Bevaka consecutiveFailures och lastError i
GET /v1/webhook-endpoints, eller sidan Webhooks i portalen, och larma på
dem innan de når tjugo.
7. Dubbletter och ordning
Varje händelse kan komma mer än en gång
Leveransen sker minst en gång. En timeout efter att din kod redan har
behandlat händelsen, ett nytt försök eller en omsändning skickar den igen, med samma
custas-event-id. Registrera de id:n du har behandlat, och hoppa över ett
som du redan sett:
INSERT INTO custas_events (event_id, received_at) VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING; -- 0 rader: redan hanterad, svara 2xx och avbryt
Gör det i samma transaktion som jobbet, så att en krasch inte kan registrera id:t utan jobbet eller jobbet utan id:t. Spara id:na i minst några dagar: omsändningar kommer från leveransloggen, som sträcker sig 30 dagar bakåt.
Händelser kan komma i fel ordning
Leveranser skickas parallellt och får nya försök oberoende av varandra, så
points.earned för måndagens köp kan komma efter tisdagens. Utgå aldrig
från att den senast mottagna händelsen visar det senaste läget:
-
Jämför
occurredAtmed det du har sparat, och ignorera en äldre händelse för en resurs som du har nyare data om. -
Eller se händelsen som en signal: vid
member.updatedhämtar du medlemmen och sparar det API:et returnerar. API:et svarar alltid med aktuellt läge, oavsett i vilken ordning händelserna kom. -
Händelser för poäng och presentkort innehåller det resulterande
balanceellerbalanceMinor. Behåll värdet med senastoccurredAti stället för att summeradelta: då kan en missad eller dubblerad händelse inte ge dig fel värde för gott.
8. Leveransloggen och omsändning
GET /v1/webhook-deliveries?endpointId={id}&status=abandoned
POST /v1/webhook-deliveries/{deliveryId}/replay # → 202 { "status": "pending" }
-
Varje leverans visar sitt
eventId,eventType,status(pending,delivering,delivered,failedellerabandoned),attemptCountsamt ditt senasteresponseStatus,responseBodyochlastError. - Omsändning skickar samma händelse, med samma id, och ger den nio nya försök. Det fungerar även på en levererad händelse, vilket är användbart när du har rättat en bugg i hur du hanterade den. Din dubblettfiltrering hoppar över den om du inte först tar bort det id:t.
- En leverans som försöks just nu svarar
409. - Händelser sparas i 30 dagar, längre om en leverans fortfarande väntar på ett nytt försök. Därefter stämmer du av via API:et i stället.
- Sidan Webhooks i portalen visar samma logg, med en knapp för omsändning.
9. Byta hemlighet
PATCH /v1/webhook-endpoints/{id} { "rotateSecret": true } # → 200, med den nya "secret" en enda gång
Den nya hemligheten signerar varje leverans från och med det ögonblicket, och den gamla slutar fungera direkt: det finns ingen överlappningsperiod, och en leverans har en enda signatur.
- Byt hemligheten och lägg in den nya i ditt hemlighetsvalv direkt.
- Leveranser som kommer innan din mottagare har den nya hemligheten misslyckas med verifieringen. Det gör inget: de försöks igen inom en minut, igen en minut senare, och går igenom så fort mottagaren har den nya hemligheten.
- Om din mottagare körs på flera servrar som läser in konfigurationen vid olika tidpunkter, låt var och en godta en signatur från antingen den gamla eller den nya hemligheten under bytet, och ta sedan bort den gamla.
Byt hemlighet så fort den kan ha exponerats: i en logg, en skärmbild eller ett repository. En förlorad hemlighet kan bara bytas, aldrig läsas ut igen.
10. Checklista
- Separata endpoints registrerade för test och skarp drift
- Bara de händelsetyper du hanterar är prenumererade
- Hemligheten ligger i ett hemlighetsvalv, aldrig i kod eller loggar
- Signaturen verifieras på den råa bodyn, i konstant tid
- Tidsstämplar som är äldre än fem minuter nekas
- Händelser sparas och besvaras inom 10 sekunder; jobbet görs efteråt
- Behandlade händelse-id:n registreras i samma transaktion som jobbet
-
Ordningen kommer från
occurredAteller en ny läsning, aldrig från mottagningsordningen member.deletedraderar medlemmen i dina systemmember.mergedpekar om dina poster till medlemmen som behålls- Ett larm går på
consecutiveFailureslångt innan tjugo - En plan för att stämma av via API:et efter ett avbrott
- Byte av hemlighet har övats i testmiljön