Webhooks

Graal pushes events to you. Instead of polling an API to notice that something changed, you register an HTTPS endpoint once and receive an HTTP POST each time a business event happens.

This page is the whole story: how to subscribe, what arrives, how to verify it, and what happens when your endpoint is down. It applies to every Graal API that raises events.

How the pieces fit together

Two services are involved, and knowing which does what saves a lot of confusion.

The *producing* API is where the business event happens. The Purchase API is the first one: validating a purchase order raises an event there. A producing API never posts to your endpoint itself.

The *Webhook* API owns delivery. It holds your subscriptions, builds the envelope, posts it to your endpoint, retries when that fails, and keeps the delivery history. It is domain-agnostic: it does not understand purchase orders, and it never reshapes what the producer published.

So you read the producing API to learn which events exist and what their payloads look like, and you use the Webhook API to actually receive them.

Before you start

Every operation requires an OAuth2 bearer token, and your client must be registered as an integrator. Registration is what binds your credentials to a tenant. An unregistered client receives 403 on every operation, however valid the token otherwise is.

Registration is per API. Being onboarded on the Purchase API does not by itself onboard you on the Webhook API. If reads against a producing API work while everything on the Webhook API returns 403, that is the cause. Onboarding is not self-service - contact support.

Subscribing

Create a subscription with POST /GraalWebhook/REST/Webhooks/Subscriptions. You supply the destination url, the list of eventTypes you want, and how the request should authenticate to your endpoint.

GET /GraalWebhook/REST/Webhooks/EventTypes returns the catalogue of every event type you may subscribe to, together with the service that produces it.

Event types are dotted names such as purchaseOrder.validated. You may subscribe with a wildcard, but wildcards match a single segment only: purchaseOrder.* matches purchaseOrder.validated, and does not match purchaseOrder.row.added.

Four authentication modes are available for the call to your endpoint: None, ApiKey, Hmac and OAuth2. Use Hmac unless you have a reason not to - it is the only one that proves the body was not tampered with in transit.

What arrives

Every delivery is an HTTP POST to the url you registered, with Content-Type: application/json. The body is an envelope with a fixed shape, described as WebhookEventEnvelope in the Webhook API reference.

The envelope carries the event identity - eventId, eventType, occurredAt - the entity it concerns as entityId and reference, a resourceUrl to fetch the entity, the subscriptionId that produced the delivery, and the payload itself.

Each delivery also carries five headers:

  1. X-Webhook-Event-Id - identifies the integration event. Stable across every retry and every replay. This is the one to deduplicate on.

  2. X-Webhook-Delivery-Id - identifies this delivery attempt series. A replay gets a new one.

  3. X-Webhook-Event-Type - for example purchaseOrder.validated.

  4. X-Webhook-Subscription-Id - the subscription that produced the delivery.

  5. X-Webhook-Attempt - 1 on the first attempt, incremented on every retry.

The payload

The payload member carries whatever the producing service published, forwarded byte for byte and never reshaped. Its schema therefore belongs to the producer, not to the Webhook API.

For purchaseOrder.* events the payload is the Purchase API's PurchaseOrderDTO - and more precisely, it is byte for byte the body that GET /GraalPurchase/REST/PurchaseOrders/{id} returned at the moment the event occurred. Same serializer, same property order, same casing, same enum spelling. One deserializer handles both the REST response and the event payload. That is a guarantee, not an accident of the current implementation.

Each producing API documents its own events and their payload schemas, both as a table in its description and as a machine-readable callbacks block on the operation that raises the event.

Large payloads

A payload above 204,800 bytes of UTF-8 is not embedded. The envelope then arrives with payload set to null and payloadOmitted set to true, and you fetch the entity yourself from resourceUrl.

Handle this from day one. It is a normal condition, not an error, and a single unusually large order is enough to trigger it.

resourceUrl is a path, not an absolute URL - for example /GraalPurchase/REST/PurchaseOrders/{id}. Resolve it against the gateway base URL for your environment. Note that it returns the entity's current state, which may have moved on since the event, so it is not equivalent to the snapshot an embedded payload gives you.

Acknowledging a delivery

Answer with any 2xx status to acknowledge. Anything else, or no answer within 30 seconds, counts as a failure and schedules a retry. The response body is never read.

Acknowledge first and process afterwards if your processing is slow. A delivery that takes longer than 30 seconds to handle will be retried even though you received it successfully.

Retries

Retries are exponential, starting at 60 seconds, doubling each time, capped at 4 hours: 1m, 2m, 4m, 8m, 16m, 32m, 64m, 128m, 4h, 4h, and so on.

A delivery is abandoned 24 hours after it was queued, whatever the attempt count has reached. Abandoned deliveries remain readable on GET /GraalWebhook/REST/Webhooks/Deliveries and can be sent again with POST /GraalWebhook/REST/Webhooks/Deliveries/{id}/replay.

Auto-disable

A day on which at least one of your deliveries is abandoned counts as one failure day, however many were abandoned that day. Ten consecutive failure days disable the subscription, and deliveries stop.

Any successful delivery resets the counter to zero. Re-enable a disabled subscription with PATCH /GraalWebhook/REST/Webhooks/Subscriptions/{id}/status, which also resets the counter.

Guarantees, and what is not guaranteed

Delivery is at least once. Retries and replays mean the same eventId can arrive more than once, so your handler must be idempotent on eventId.

There is no ordering guarantee. Deliveries are drained in batches and retried independently, so a later event can overtake an earlier one that is still retrying. Order on occurredAt, never on arrival order.

There is no cross-tenant leakage. A subscription only ever receives events raised for the tenant it was created under.

Nothing is collapsed by default: every event produces its own delivery. If you would rather only see the latest state per entity, set supersedePendingDeliveries on the subscription - at the cost of losing the intermediate events.

Verifying the signature

When a subscription uses Hmac, each delivery carries a signature header, named X-Webhook-Signature unless you chose another name.

The canonical string is the raw request body, exactly as received, byte for byte. There is no timestamp, no HTTP method and no path mixed in, and no re-serialization. The signature is the base64 encoding of HMAC-SHA256(secret, rawRequestBody).

It is computed once, when the delivery is queued, so it is identical on every retry and on every replay of that delivery.

Verify before parsing, and compare in constant time. In C#:

using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody)));
var ok = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(request.Headers["X-Webhook-Signature"]));

In Node:

const expected = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('base64');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get('X-Webhook-Signature')));

rawBody must be the unparsed body. Re-serializing a parsed object changes whitespace and key order, and the signature will not match. This is the single most common integration mistake.

Events available today

The Purchase API is the first producer. It raises three event types.

purchaseOrder.validated fires when a purchase order is validated and has been assigned its business reference, following a successful POST /GraalPurchase/REST/PurchaseOrders/{id}/validate. An amendment raises it too, under its own identifier.

purchaseOrder.cancelled fires when a purchase order is cancelled and will not be executed, following a successful POST /GraalPurchase/REST/PurchaseOrders/{id}/cancel.

purchaseOrder.sent is declared and you can already subscribe to it, but nothing raises it yet. No delivery will arrive for it until the supplier-sending flow ships. It is listed so that you can build against it now.

Inspecting what happened

GET /GraalWebhook/REST/Webhooks/Deliveries returns the delivery history for your subscriptions, including the attempt count, the last HTTP status code received, the last error message, and when the next retry is due.

That history is the first place to look when an event you expected never arrived. It distinguishes the three cases that matter: no delivery was ever created, meaning no subscription matched the event type; a delivery exists and is still retrying; or a delivery was abandoned, in which case the recorded status code and error say why your endpoint rejected it.