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

Optional
  • 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.

Context Vars: xero.webhook.key