How it works
- You register an endpoint: a public https URL on your backend, per environment (Sandbox, Live), subscribed to the event types you want (
["*"]for all). From the dashboard (Webhooks tab) or withPOST /v1/webhook-endpoints(ensc.webhookEndpoints.create). - ENSC delivers: one
POSTper event, JSON body, signed headers. - You verify, acknowledge, then process: check the signature over the raw body, answer
200, and do the work afterwards. - ENSC retries until your endpoint answers 2xx or the attempts run out, so you must handle duplicates.
Registering an endpoint
PATCH with status), changed (url, eventTypes, description) and deleted. A disabled endpoint receives no new events, and retries already scheduled for it stop. With the SDK: webhookEndpoints.list(), get(id), update(id, { ... }), remove(id).
No secret is issued when you register. Deliveries are signed with ENSC’s own key (next section), so there is nothing to store or rotate on your side.
Scopes: a secret key manages webhooks and reads the event log. A restricted key needs webhooks:read to list and read, webhooks:manage to create, change, test or delete endpoints.
The delivery
X-ENSC-Webhook-Id is unique per delivery attempt and is part of the signed string.
Verifying a delivery
The signature is Ed25519 overX-ENSC-Key-Id. The public keys are published at GET /v1/.well-known/ensc-public-keys.json (entries with use containing webhooks); EnscClient.fetchPublicKeys() loads them as { [kid]: publicKey }. Fetch the document once, cache it per key id, and refetch on an unknown id: that is how a key rotation reaches you without any action on your side.
Rules that keep the check sound:
- Hash the raw bytes you received. Parsing the JSON and re-serialising it changes the bytes and the signature no longer matches.
- Refuse a delivery whose timestamp is more than 5 minutes from your clock; a retry always carries a fresh timestamp and signature.
- Compare in constant time, or use the SDK.
@ensc/sdk:
constructEvent takes body as a string or bytes, exactly as received and before any JSON parsing, headers as a Headers instance or a plain record (Node’s req.headers works), and publicKey, the key map from EnscClient.fetchPublicKeys() (a single key string is accepted too). The verifier picks the key named by X-ENSC-Key-Id, so a key rotation needs no redeploy. It throws ENSC_INVALID_SIGNATURE when the signature does not verify under that key, the key id is not in the map you pass, or the timestamp is outside the window, and ENSC_VALIDATION_FAILED when the verified body is not an event envelope. EnscClient.verifyWebhookSignature is the non-throwing form and returns { valid, reason? }; reason is unknown_key_id when the key id is not in your map, which is your cue to refetch the keys. Any Ed25519 implementation can do the same check without the SDK; the SDK’s test suite carries the vectors.
Answering and processing
- Answer 2xx within 15 seconds. ENSC waits that long. Verify, record the event id, answer, then process. Do not call your bank, ENSC or a chain node before answering.
- Any other outcome is retried: a non-2xx answer, a redirect, a timeout or a connection failure. The retries come after 1 minute, 5 minutes, 15 minutes, 1 hour, 2 hours, 4 hours and 8 hours: 8 attempts in total over about 15 hours. Then the delivery is marked
gave_up. Disabling the endpoint ends its retries. - Delivery is at least once. A retry after a lost answer delivers the same event id again. Store the ids you have processed and skip duplicates.
- Order is not guaranteed. Two events for one conversion can arrive out of order or seconds apart. Act on the
statusthe event carries, not on the sequence, and before releasing goods or money read the conversion back withGET /v1/conversions/{reference}(ensc.conversions.get(reference)). - Credit on the final event:
conversion.succeededfor crypto legs andfiat-issue,payout.succeededfor afiat-redeem. Earlier events are progress, not money. - Ignore fields you do not know. New fields can appear in a payload; write the handler so they do not break it.
Seeing what was delivered
GET /v1/events (ensc.events.list) is the log of every event ENSC generated for you, with env and type filters. GET /v1/events/{id} (ensc.events.get) adds the delivery attempts: for each, the endpoint, the attempt number, status (pending, delivered, failed, gave_up), the HTTP status your endpoint answered, when the next attempt is due and the first 500 characters of your response. The dashboard shows the same log.
Testing your receiver
- Deploy the receiver to the URL you will register, on the same hosting as the rest of your backend.
- Register that URL in Sandbox.
POST /v1/webhook-endpoints/{id}/test(ensc.webhookEndpoints.sendTest(id, { eventType })) queues one signed event in that endpoint’s environment; every active endpoint there that subscribes to the type receives it, and the response says whether the endpoint you named is among them (willDeliverToTargetEndpoint). With aneventTypefrom the catalogue and nopayload, the delivery carries the fields a real event of that type carries; with noeventTypeit is asynthetic.test_event. A Live endpoint accepts onlysynthetic.test_event(ENSC_TEST_LIVE_MISMATCHotherwise), so a real event type can never be forged into a Live receiver.POST /v1/test-data/events(ensc.testEvents.emit) sends a realistic event to every Sandbox endpoint, andGET /v1/test-data/events(ensc.testEvents.list) shows the catalogue with a sample payload per type.- Read the attempt back with
GET /v1/events/{eventId}and confirmstatus: "delivered"with your200.