Webhooks
Get order and buyer events from Sorai as they happen, and verify that each one came from Sorai.
Sorai can send your server a request each time something happens to an order or a buyer: an order is paid, fulfilled or refunded, a new buyer checks out, access is granted or taken away. This page covers setting up an endpoint, the payload, verifying the signature, retries and every event.
Set up an endpoint
In the Sorai console, open Integrations, then Webhooks, and add your
endpoint's URL. Sorai creates a signing secret that starts with whsec_ and
shows it to you once. Store it somewhere safe. A lost secret cannot be
shown again or rotated in place: delete the endpoint and add a new one.
An endpoint receives every event unless you pick a list of events for it, when you add it or later from its settings.
Payload
Every delivery is a POST with a JSON body of this shape:
{
"version": "2026-08-01",
"event": "order.paid",
"id": "018f1b1a-0000-7000-8000-000000000000",
"created_at": "2025-01-01T00:00:00Z",
"data": {
"order_id": "ord_123",
"order_number": "SR-ABCDEFGHIJ",
"store_id": "store_456",
"status": "paid",
"currency": "IDR",
"total_minor": 150000,
"subtotal_minor": 150000,
"discount_minor": 0,
"discount_code": "",
"buyer": {
"id": "buyer_789",
"email": "buyer@example.com",
"name": "Rina"
},
"items": [
{
"product_id": "prod_1",
"price_id": "price_1",
"name": "Ebook",
"kind": "primary",
"quantity": 1,
"unit_amount_minor": 150000,
"amount_minor": 150000
}
],
"custom_fields": { "game_id": "12345" },
"payment": { "method": "qris", "amount_minor": 151500, "fee_minor": 1500 }
}
}| Field | Type | Meaning |
|---|---|---|
version | string | The version of this envelope. It changes only if the envelope's shape changes, not with every release. |
event | string | The event's name. See Events. |
id | string | A UUID for this event. Every retry of the same event carries the same id, so it is the key to deduplicate on. |
created_at | string | When Sorai recorded the event, as an RFC 3339 timestamp. |
data | object | The event's own fields, which depend on event. Always enough to identify the order, buyer or entitlement without another call. |
Every money field is an integer in the currency's minor unit, never a float.
Verify the signature
Every delivery carries three headers:
| Header | Meaning |
|---|---|
X-Sorai-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
X-Sorai-Event | The same value as the body's event, so you can route a request without parsing it. |
X-Sorai-Delivery | A UUID for this delivery attempt. Not the body's id: an event can be delivered more than once, and each attempt has its own. |
The signature is:
signed_payload = "{timestamp}.{raw_request_body}"
signature = hex(HMAC-SHA256(webhook_secret, signed_payload))timestamp is the t= value from the header. raw_request_body is the exact
bytes of the body as they arrived. Verify against the raw body your framework
gives you, before any JSON parsing: parsing and re-encoding can reorder keys
or change whitespace, and the signature will no longer match.
Check two things:
- The MAC. Compute it and compare it with
v1in constant time:hmac.Equalin Go,hmac.compare_digestin Python,crypto.timingSafeEqualin Node. Never==, which leaks timing an attacker can use to forge a signature byte by byte. - The timestamp's age. Reject a request whose
tis too far from now. The timestamp is part of what is signed, so a captured request cannot be replayed later by changing an unsigned header. Five minutes is a good default.
Test vector
Use this to check your implementation.
Secret:
whsec_5f3a2b1c4d5e6f708192a3b4c5d6e7f8Timestamp: 1735689600 (2025-01-01T00:00:00Z)
Raw request body (the exact bytes, on one line):
{"version":"2026-08-01","event":"order.paid","id":"018f1b1a-0000-7000-8000-000000000000","created_at":"2025-01-01T00:00:00Z","data":{"order_id":"ord_123","order_number":"SR-ABCDEFGHIJ","total_minor":150000,"currency":"IDR"}}Expected X-Sorai-Signature:
t=1735689600,v1=c33ffe12039b04e193486c8f8b5fd6dd15bfdcf5a8bdd565270c5f2c5ba348dcReproduce it in Python:
import hmac, hashlib
secret = b"whsec_5f3a2b1c4d5e6f708192a3b4c5d6e7f8"
ts = "1735689600"
body = b'{"version":"2026-08-01","event":"order.paid","id":"018f1b1a-0000-7000-8000-000000000000","created_at":"2025-01-01T00:00:00Z","data":{"order_id":"ord_123","order_number":"SR-ABCDEFGHIJ","total_minor":150000,"currency":"IDR"}}'
signed_payload = ts.encode() + b"." + body
signature = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
print(f"t={ts},v1={signature}")Verifier in Node
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the request body as a Buffer, before any JSON parsing.
export function verifySoraiSignature(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}Verifier in Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
)
// body is the request body exactly as it arrived, before any JSON parsing.
func VerifySoraiSignature(secret, header string, body []byte, now time.Time, tolerance time.Duration) error {
var ts int64
var sig []byte
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(part, "=")
switch key {
case "t":
ts, _ = strconv.ParseInt(value, 10, 64)
case "v1":
sig, _ = hex.DecodeString(value)
}
}
if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
return errors.New("timestamp outside tolerance")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
mac.Write(body)
if !hmac.Equal(sig, mac.Sum(nil)) {
return errors.New("signature mismatch")
}
return nil
}Retries
Answer with any 2xx status to accept a delivery. Anything else, a timeout or
a connection error counts as a failure, and Sorai tries again. A delivery gets
8 attempts in all, each after this delay:
| Attempt | Delay before it | Time since the event |
|---|---|---|
| 1 | 15 seconds | 15s |
| 2 | 1 minute | 1m 15s |
| 3 | 5 minutes | 6m 15s |
| 4 | 15 minutes | 21m 15s |
| 5 | 1 hour | about 1h 21m |
| 6 | 4 hours | about 5h 21m |
| 7 | 12 hours | about 17h 21m |
| 8 | 24 hours | about 41h 21m |
When the 8th attempt fails too, the delivery is marked failed and Sorai
stops. You can send it again from the console: open the endpoint's
deliveries and choose Replay. A replay starts the same 8 attempts again.
Endpoints that keep failing are turned off. After 50 deliveries in a row have each failed all 8 attempts, the endpoint is disabled and stops receiving events. One successful delivery resets the count, so an endpoint that is down now and then is never disabled; only an unbroken run of 50 failures does it. Fix the cause, then turn the endpoint back on in the console.
Deduplicate on id. An event can arrive more than once, after a retry or
a replay. Process each id once, however many requests carry it.
Events
Sorai sends nine events. For one successful order, they arrive in this order:
| Event | Sent when |
|---|---|
order.created | An order is placed, before it is paid. |
buyer.created | An email checks out from your org for the first time. A repeat purchase from the same email does not send it. |
entitlement.granted | Payment settles and the buyer's access is recorded, once per entitlement. Download links do not exist yet. |
order.paid | Payment for the order settles. |
order.fulfilled | Access is granted and everything the buyer bought is ready to use. |
order.expired | An unpaid order's time to pay runs out. |
order.cancelled | You cancel an unpaid order from the console. |
order.refunded | A paid or fulfilled order is refunded. It is sent once per refund. |
entitlement.revoked | Right after order.refunded, once for each entitlement the refund took away. |
An endpoint can only subscribe to these names. Asking for any other name is
refused with 422 invalid_event, so an endpoint never looks set up while
waiting for an event that will never come. An endpoint that subscribes to
everything (the default) starts receiving a new event the day Sorai starts
sending it, with no change on your side.
If an event is ever retired, an endpoint subscribed to it keeps working:
saving the endpoint drops the retired name from its list instead of failing.
The one exception is an endpoint whose list would be left empty, which would
mean "everything"; that save is refused with 422 invalid_event instead.
webhook.test is not an event you subscribe to. It is what the console's
Test button sends; see Test an endpoint.
Order events
order.created, order.paid, order.expired, order.cancelled and
order.refunded carry:
| Field | Type | Meaning |
|---|---|---|
order_id | string | The order's UUID. |
order_number | string | The number the buyer sees, such as SR-ABCDEFGHIJ. |
store_id | string | The UUID of the order's store. |
store_name | string | The store's name when Sorai started sending this order's events, usually when it was placed. Absent on older orders. |
store_slug | string | The store's slug, as in its link. Absent when store_name is. |
status | string | The status this event reports: awaiting_payment, paid, expired, cancelled or refunded. On order.created for a free order it is pending, because there is no payment to await. |
currency | string | An ISO 4217 code. |
total_minor | integer | The order's total: subtotal_minor - discount_minor. Does not include a payment fee the buyer paid on top (see payment). |
subtotal_minor | integer | The sum of every line's amount_minor, before the discount. |
discount_minor | integer | What the discount code took off; 0 without one. |
discount_code | string | The code the buyer used; "" without one. |
buyer | object | Who placed the order: id, email, name, and phone and country when the checkout collected them. On every order, not only a buyer's first. |
items | array | One entry per line. See below. |
custom_fields | object | The buyer's answers to your checkout form, keyed by each field's key; {} when it asked nothing. Each answer is at most 1000 characters. A renamed field keeps its old key on older orders. |
attribution | object | The utm_source, utm_medium, utm_campaign, utm_term, utm_content and referrer the buyer arrived with; {} when none. The buyer can edit these. |
metadata | object | What your server set on the checkout session the order came from; {} otherwise. The buyer cannot change it. |
checkout_session_id | string | Optional. The checkout session's id. Absent for an ordinary checkout. |
originating_order_id | string | Optional. Set on an order bought from a post-payment offer: the order that offer was shown after. That order's metadata is copied here too, so check this before acting on it twice. |
Each entry in items:
| Field | Type | Meaning |
|---|---|---|
product_id | string | The product's UUID. |
price_id | string | The price's UUID. Two variants of a product differ here. |
name | string | The line's name as the buyer saw it at checkout. |
kind | string | primary, or bump for an add-on accepted at checkout. |
quantity | integer | Units bought. |
unit_amount_minor | integer | The price of one unit. |
amount_minor | integer | unit_amount_minor times quantity. |
order.paid also carries payment, read when the order was paid. The buyer
may have switched payment method since order.created.
| Field | Type | Meaning |
|---|---|---|
payment.method | string | How the buyer paid, such as qris, va_bca, card or free. |
payment.amount_minor | integer | What the buyer was charged, the payment fee included. |
payment.fee_minor | integer | The payment fee the buyer paid on top of total_minor; 0 when you pay it. |
Every field is fixed when the order's events begin, and a retry never
re-reads the order. A retried order.created still says awaiting_payment
after the buyer has paid, so it deduplicates against the first attempt
instead of looking like a new event. An older order may arrive without some
of the newer fields, so check each one on its own.
order.fulfilled
Everything above, with status: "fulfilled", plus:
| Field | Type | Meaning |
|---|---|---|
entitlement_ids | array of strings | Every entitlement the order granted, so you can provision access without another call. |
payment | object | As on order.paid. |
license_keys | array | One entry per license key the order created: id, entitlement_id, product_id and last4. Empty when none. |
A webhook never carries the license key itself. A delivery is logged, retried
for hours and may go to a plain http endpoint or a tool that keeps every
run, and a key kept there would outlive a refund that revoked it. last4 is
enough to tell keys apart; fetch a full key from your server with a secret
API key that has the orders:read scope.
buyer.created
| Field | Type | Meaning |
|---|---|---|
buyer_id | string | The buyer's UUID. |
email | string | The buyer's email. A buyer is one email per org. |
name | string | Can be empty if the checkout did not ask for one. |
phone | string | Optional. Absent, not empty, when the checkout did not collect it. |
country | string | Optional. ISO 3166-1 alpha-2. Absent when not collected. |
store_id | string | The store whose checkout the buyer first used. |
first_order_id | string | The order that created the buyer, so the event stands on its own in a CRM. |
entitlement.granted
Sent once per entitlement, not once per order, so you can provision access
product by product and deduplicate on entitlement_id.
| Field | Type | Meaning |
|---|---|---|
entitlement_id | string | The entitlement's UUID. A retry carries the same one. |
buyer_id | string | Who owns it now. |
order_id | string | The order that granted it. |
product_id | string | The product. |
kind | string | The product's kind, such as file, course, license or service. |
expires_at | string | Optional. RFC 3339. Absent when access does not expire. |
entitlement.revoked
The refund's counterpart: sent once for each entitlement a refund took away, so you can remove access the same way you granted it.
| Field | Type | Meaning |
|---|---|---|
entitlement_id | string | The same id the entitlement.granted event carried. |
buyer_id | string | Who owned it. |
order_id | string | The refunded order. |
product_id | string | The product. |
kind | string | The product's kind, as on entitlement.granted. |
revoked_at | string | RFC 3339. A retry reports the first attempt's time, never a new one. |
A refunded buyer loses access at once: their download links stop working and every license key the order created is revoked. Only a refund revokes an entitlement, and it cannot be undone.
Which event to listen to
- The money arrived:
order.paid. Record the sale, tell your team, or start anything that only needs to know it is paid. - The buyer can use what they bought:
order.fulfilled. Provision access in your own app. It comes after entitlements and license keys exist, and carries their ids. - A new customer:
buyer.created, once per email per org. For every purchase, useorder.paid, which carriesbuyeron every order. - Take access away:
order.refunded, then oneentitlement.revokedper entitlement.
Every event is delivered at least once. Deduplicate on the envelope's id.
Match a sale to your own user
To know which of your users bought, open the checkout from your server: create
a checkout session with a secret API key that has the checkout:write scope,
put your own id in its metadata (for example {"user_id": "u_123"}), and
send the buyer to the session's url. The order's events then carry
metadata.user_id and checkout_session_id.
A session link takes one payment and expires after a day unless you set otherwise. Pay-what-you-want prices cannot be sold this way, and discount codes are off unless the session allows them.
Only metadata is safe to act on. custom_fields, hidden fields included,
come from the buyer or from a link they can edit. Use them for something like
a campaign tag, never to decide whose account to credit or which invoice is
paid. Whatever you match on, check status, items and total_minor for
what was actually bought.
Tag a sale with a campaign or source
UTM parameters on the link a buyer arrives by come back in attribution
without any setup. For a tag of your own, add a Hidden field to the
store's checkout form, say partner, and add ?partner=acme to a checkout
or product page link (or pass fields: { partner: "acme" } to
Sorai.checkout()). The buyer never sees it, and it comes back as
custom_fields.partner.
Both are for reporting. A buyer can edit the link, so neither should decide
whose account to credit or which invoice is paid: use metadata for that.
Test an endpoint
The Test button on an endpoint in the console sends one webhook.test
event straight away, signed like a real delivery, and shows you the status
your server answered with. Its data is a sample of a real event with
example values: the endpoint's first subscribed event, or order.paid when
it subscribes to everything.
Use it to map fields in a tool such as n8n before your first sale. Route on
event so a test never starts real work. A test is not retried and does not
count toward the endpoint being turned off, so send as many as you like.
Receive webhooks in n8n
- Add a Webhook node with method
POST, and under Options turn on Raw Body. The signature covers the exact bytes Sorai sent, and a re-encoded body will not verify. - Add a Crypto node: action Hmac, type SHA256, encoding Hex,
and your
whsec_secret as the secret. Its value is thet=number fromX-Sorai-Signature, a., then the raw body as text, as in the test vector. - Add an IF node that compares the result with the
v1=part ofX-Sorai-Signature, and drops the request when they differ or whentis more than five minutes old. - Add a Switch on
{{ $json.body.event }}with a branch for each event you handle, and ignorewebhook.test. - Press Test on the endpoint in the console to send a sample, then map fields from it.
The exact expressions depend on your n8n version. The rule they implement is the one in Verify the signature.

