Xero webhooks: signature verification, the intent-to-receive handshake, and event parsing.
Signature. Xero signs each delivery with the webhook's signing
key (Developer portal, your app, Webhooks): x-xero-signature is the
base64 HMAC-SHA256 of the raw body keyed with it. verify-request
recomputes it over ::hot::http/raw-body and compares in constant
time. An unset or empty key never verifies.
Intent to receive. When you save the webhook URL, Xero sends
signed test deliveries, some with a deliberately wrong signature. The
endpoint must answer 200 with an empty body when the signature is
valid and 401 with an empty body when it is not, within five
seconds, or Xero will not enable the webhook. Normal deliveries use
the same rule, so respond(request) is all a handler needs to
return; receive(request) also parses the events.
Payload. {events: [{resourceUrl, resourceId, eventDateUtc, eventType, eventCategory, tenantId, tenantType}], firstEventSequence, lastEventSequence, entropy}. Categories include INVOICE,
CONTACT and CREDITNOTE (eventType CREATE / UPDATE); events
carry ids only, so fetch the record (resourceUrl) before acting.
Xero signs no timestamp, so dedupe on resourceId + eventDateUtc
and treat events as hints. Acknowledge fast and do the work in a Hot
event handler.
xero-webhook
meta {webhook: {service: "xero", path: "/xero/webhook"}}
fn (request) {
received ::xero::webhooks/receive(request)
if(received.verified,
for-each(received.events, (e) { ::hot::event/send("xero:changed", e) }),
null)
received.response
}
Context Vars
xero.webhook.key
Functions
ctx-key
fn (): Str
normalize-event
fn (e: Map): Map
parse-events
fn (body: Any): Map
Parse a webhook body (decoded Map, or the raw Str/Bytes) into
{events, first-sequence, last-sequence, is-intent}. Each event is
{resource-url, resource-id, event-date, event-type, event-category, tenant-id, tenant-type}. is-intent is true for an
intent-to-receive test delivery (no events). An Err for invalid JSON.
receive
fn (request: Map): Map
fn (request: Map, webhook-key: Str): Map
Verify and parse a delivery: {verified, events, is-intent, response}.
response is what to return (200 valid, 401 invalid, both with an
empty body); events are [] when the signature is invalid.
respond
fn (request: Map): ::hot::http/HttpResponse
fn (request: Map, webhook-key: Str): ::hot::http/HttpResponse
The HttpResponse Xero expects: 200 with an empty body when the signature is valid, 401 with an empty body when it is not. Handles the intent-to-receive handshake and normal deliveries alike.
verify-request
fn (request: Map): Bool
fn (request: Map, webhook-key: Str): Bool
True when x-xero-signature is the base64 HMAC-SHA256 of the raw body under the webhook key (constant-time compare). The 1-arity form reads xero.webhook.key and fails the run when it is unset; an explicit empty key returns false.
xero.webhook.key