Hoppa till innehållet
Utvecklare

Webhooks, från första händelsen till sista försöket.

Custas meddelar ditt system när något händer: ett köp genomförs, en medlem går med, poäng tjänas in, ett presentkort används. Den här guiden visar hur du tar emot händelserna säkert och håller datan korrekt när leveranser kommer två gånger, sent eller i fel ordning.

Referensen gäller för varje fält. Exemplen är förkortade till det som spelar roll för steget.

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:read listar endpoints och leveranser; webhooks:write skapar, ä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. Med tenantId i 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 secret direkt. 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. Vanlig http:// nekas vid registrering (422, fält url).
  • 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ältet url) 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 3xx räknas som en misslyckad leverans, så registrera den slutliga URL:en.
Referens: webhook-endpoints

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:

  1. Läs bodyns råa byte innan någon JSON-parsning. Att parsa och serialisera om ändrar byten, och då stämmer inte signaturen.
  2. Beräkna HMAC-SHA256 av <custas-timestamp>.<raw body> med hela hemligheten som nyckel, inklusive prefixet whsec_. Hex-koda resultatet och sätt v1= framför.
  3. Jämför med custas-signature i konstant tid.
  4. Neka en custas-timestamp som 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 2xx som 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 2xx och 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 occurredAt med 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.updated hä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 balance eller balanceMinor. Behåll värdet med senast occurredAt i stället för att summera delta: 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, failed eller abandoned), attemptCount samt ditt senaste responseStatus, responseBody och lastError.
  • 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.

  1. Byt hemligheten och lägg in den nya i ditt hemlighetsvalv direkt.
  2. 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.
  3. 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 occurredAt eller en ny läsning, aldrig från mottagningsordningen
  • member.deleted raderar medlemmen i dina system
  • member.merged pekar om dina poster till medlemmen som behålls
  • Ett larm går på consecutiveFailures lå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