SoraiDocs
GuidesAPI reference

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 }
  }
}
FieldTypeMeaning
versionstringThe version of this envelope. It changes only if the envelope's shape changes, not with every release.
eventstringThe event's name. See Events.
idstringA UUID for this event. Every retry of the same event carries the same id, so it is the key to deduplicate on.
created_atstringWhen Sorai recorded the event, as an RFC 3339 timestamp.
dataobjectThe 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:

HeaderMeaning
X-Sorai-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See below.
X-Sorai-EventThe same value as the body's event, so you can route a request without parsing it.
X-Sorai-DeliveryA 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:

  1. The MAC. Compute it and compare it with v1 in constant time: hmac.Equal in Go, hmac.compare_digest in Python, crypto.timingSafeEqual in Node. Never ==, which leaks timing an attacker can use to forge a signature byte by byte.
  2. The timestamp's age. Reject a request whose t is 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_5f3a2b1c4d5e6f708192a3b4c5d6e7f8

Timestamp: 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=c33ffe12039b04e193486c8f8b5fd6dd15bfdcf5a8bdd565270c5f2c5ba348dc

Reproduce 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:

AttemptDelay before itTime since the event
115 seconds15s
21 minute1m 15s
35 minutes6m 15s
415 minutes21m 15s
51 hourabout 1h 21m
64 hoursabout 5h 21m
712 hoursabout 17h 21m
824 hoursabout 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:

EventSent when
order.createdAn order is placed, before it is paid.
buyer.createdAn email checks out from your org for the first time. A repeat purchase from the same email does not send it.
entitlement.grantedPayment settles and the buyer's access is recorded, once per entitlement. Download links do not exist yet.
order.paidPayment for the order settles.
order.fulfilledAccess is granted and everything the buyer bought is ready to use.
order.expiredAn unpaid order's time to pay runs out.
order.cancelledYou cancel an unpaid order from the console.
order.refundedA paid or fulfilled order is refunded. It is sent once per refund.
entitlement.revokedRight 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:

FieldTypeMeaning
order_idstringThe order's UUID.
order_numberstringThe number the buyer sees, such as SR-ABCDEFGHIJ.
store_idstringThe UUID of the order's store.
store_namestringThe store's name when Sorai started sending this order's events, usually when it was placed. Absent on older orders.
store_slugstringThe store's slug, as in its link. Absent when store_name is.
statusstringThe 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.
currencystringAn ISO 4217 code.
total_minorintegerThe order's total: subtotal_minor - discount_minor. Does not include a payment fee the buyer paid on top (see payment).
subtotal_minorintegerThe sum of every line's amount_minor, before the discount.
discount_minorintegerWhat the discount code took off; 0 without one.
discount_codestringThe code the buyer used; "" without one.
buyerobjectWho placed the order: id, email, name, and phone and country when the checkout collected them. On every order, not only a buyer's first.
itemsarrayOne entry per line. See below.
custom_fieldsobjectThe 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.
attributionobjectThe utm_source, utm_medium, utm_campaign, utm_term, utm_content and referrer the buyer arrived with; {} when none. The buyer can edit these.
metadataobjectWhat your server set on the checkout session the order came from; {} otherwise. The buyer cannot change it.
checkout_session_idstringOptional. The checkout session's id. Absent for an ordinary checkout.
originating_order_idstringOptional. 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:

FieldTypeMeaning
product_idstringThe product's UUID.
price_idstringThe price's UUID. Two variants of a product differ here.
namestringThe line's name as the buyer saw it at checkout.
kindstringprimary, or bump for an add-on accepted at checkout.
quantityintegerUnits bought.
unit_amount_minorintegerThe price of one unit.
amount_minorintegerunit_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.

FieldTypeMeaning
payment.methodstringHow the buyer paid, such as qris, va_bca, card or free.
payment.amount_minorintegerWhat the buyer was charged, the payment fee included.
payment.fee_minorintegerThe 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:

FieldTypeMeaning
entitlement_idsarray of stringsEvery entitlement the order granted, so you can provision access without another call.
paymentobjectAs on order.paid.
license_keysarrayOne 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

FieldTypeMeaning
buyer_idstringThe buyer's UUID.
emailstringThe buyer's email. A buyer is one email per org.
namestringCan be empty if the checkout did not ask for one.
phonestringOptional. Absent, not empty, when the checkout did not collect it.
countrystringOptional. ISO 3166-1 alpha-2. Absent when not collected.
store_idstringThe store whose checkout the buyer first used.
first_order_idstringThe 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.

FieldTypeMeaning
entitlement_idstringThe entitlement's UUID. A retry carries the same one.
buyer_idstringWho owns it now.
order_idstringThe order that granted it.
product_idstringThe product.
kindstringThe product's kind, such as file, course, license or service.
expires_atstringOptional. 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.

FieldTypeMeaning
entitlement_idstringThe same id the entitlement.granted event carried.
buyer_idstringWho owned it.
order_idstringThe refunded order.
product_idstringThe product.
kindstringThe product's kind, as on entitlement.granted.
revoked_atstringRFC 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, use order.paid, which carries buyer on every order.
  • Take access away: order.refunded, then one entitlement.revoked per 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

  1. 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.
  2. Add a Crypto node: action Hmac, type SHA256, encoding Hex, and your whsec_ secret as the secret. Its value is the t= number from X-Sorai-Signature, a ., then the raw body as text, as in the test vector.
  3. Add an IF node that compares the result with the v1= part of X-Sorai-Signature, and drops the request when they differ or when t is more than five minutes old.
  4. Add a Switch on {{ $json.body.event }} with a branch for each event you handle, and ignore webhook.test.
  5. 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.

On this page