Receiving webhook deliveries
This page describes the wire format Unioo uses when posting to your receiver and the things a robust receiver needs to handle. See Webhooks for the event catalog and Managing webhook subscriptions for the configuration side.
Request shape
Every delivery is a single HTTP POST with Content-Type: application/json. The body is a JSON object with three fields:
{
"type": "REPORT_PUBLISHED",
"eventId": "00000000-0000-0000-0000-000000000000",
"payload": { "reportId": "11111111-1111-1111-1111-111111111111" }
}
| Field | Type | Notes |
|---|---|---|
type | string | The event type, e.g. REPORT_PUBLISHED. Always UPPER_SNAKE_CASE. |
eventId | UUID | Unique identifier for this delivery. Use it as the idempotency key. |
payload | object | Group-specific payload — see below. |
Example payloads per event group
LeadEvent
LEAD_CREATED— a new lead has been created. Treat this as the entry point for tracking a prospective customer.LEAD_ACCEPTED— a lead has been accepted by the bank and is moving forward in the pipeline.LEAD_REJECTED— a lead has been rejected and will not progress further. Useful for closing out CRM records on your side.
{
"type": "LEAD_ACCEPTED",
"eventId": "00000000-0000-0000-0000-000000000000",
"payload": { "leadId": "11111111-1111-1111-1111-111111111111" }
}
OrganizationEvent
ORGANIZATION_CREATED— an organization has just been registered with the bank. Fetch the organization to see its initial state.ORGANIZATION_SURRENDERED— the organization has been released from the bank. The organization is no longer a customer.ORGANIZATION_MARKETING_CONSENT_CHANGED— the organization's marketing consent flag was updated. Re-fetch to see the new value.
{
"type": "ORGANIZATION_CREATED",
"eventId": "00000000-0000-0000-0000-000000000000",
"payload": { "organizationId": "11111111-1111-1111-1111-111111111111" }
}
InvitationEvent
INVITATION_SURRENDERED— an outstanding organization invitation has been surrendered before it was accepted. The invitation will not result in an organization being created.
{
"type": "INVITATION_SURRENDERED",
"eventId": "00000000-0000-0000-0000-000000000000",
"payload": { "invitationId": "11111111-1111-1111-1111-111111111111" }
}
BankReportEvent
REPORT_PUBLISHED— a bank report (package) has been published to the bank and is now available for review. This is normally the trigger to start the bank-side review workflow.REPORT_REJECTED— a previously published report has been rejected and will not be processed further.
{
"type": "REPORT_PUBLISHED",
"eventId": "00000000-0000-0000-0000-000000000000",
"payload": { "reportId": "11111111-1111-1111-1111-111111111111" }
}
BankReportReviewEvent
REPORT_REVIEW_CREATED— a pending review has been created for a published report. Typically fires shortly afterREPORT_PUBLISHED.REPORT_REVIEW_PROVISIONALLY_APPROVED— a reviewer has provisionally approved the report (the "looks good, awaiting second pair of eyes" stage). Not a final decision.REPORT_REVIEW_APPROVED— the review has reached final approval. The report is approved on the bank side.REPORT_REVIEW_REJECTED— the review concluded with a rejection. The organization will be asked to resubmit.
{
"type": "REPORT_REVIEW_APPROVED",
"eventId": "00000000-0000-0000-0000-000000000000",
"payload": {
"reviewId": "22222222-2222-2222-2222-222222222222",
"reportId": "11111111-1111-1111-1111-111111111111"
}
}
BankRequestEvent
BANK_REQUEST_FULFILLED— the organization has fulfilled an outstanding bank request (uploaded documents, answered a question, etc.). The request is now awaiting bank-side approval.BANK_REQUEST_APPROVED— the bank approved a fulfilled request. The request is closed successfully.BANK_REQUEST_REJECTED— the bank rejected the request. The organization will typically be asked to resubmit.
{
"type": "BANK_REQUEST_APPROVED",
"eventId": "00000000-0000-0000-0000-000000000000",
"payload": { "bankRequestId": "11111111-1111-1111-1111-111111111111" }
}
The payload carries only IDs. To get the full state of the referenced entity, query it over the GraphQL API using the ID. Doing this on receipt also avoids ordering pitfalls — see No ordering below.
Headers
Every delivery includes:
Content-Type: application/jsonX-Unioo-Secret: WH-…— the subscription's shared secret. Sent on every delivery, regardless of the subscription's authentication type.
When the subscription has Entra authentication configured, Unioo additionally obtains an OAuth 2.0 token from your Entra tenant and sends it on the same request:
Authorization: Bearer <token>
Verifying authenticity
You should never trust the request body without first authenticating the caller. Pick one of the following based on how the subscription is configured:
Shared-secret (None)
Compare X-Unioo-Secret against the secret you stored when the subscription was created. Use a constant-time comparison — most languages ship one (hmac.compare_digest in Python, crypto.timingSafeEqual in Node, MessageDigest.isEqual in Java).
If the header is missing or does not match, respond 401 Unauthorized and do not process the body.
The secret is opaque (a WH--prefixed random string) — there is no signature scheme to verify in addition. Treat the secret as a credential.
Entra OAuth (Entra)
Validate the Authorization: Bearer <token> JWT exactly as you would validate any other inbound Entra token: check the signature against your tenant's keys, the issuer, the audience (typically the API your tokenScope resolves to), and expiry.
The X-Unioo-Secret header is still sent in this mode; you can optionally check it as a defence-in-depth measure.
Responding
Return any 2xx status code to mark the delivery successful. Unioo treats every other outcome — non-2xx, network error, timeout — as a failure and schedules a retry.
Best practice for the response itself:
- Acknowledge fast. Validate the request, enqueue the work to your own background processor, and return
200 OKimmediately. Holding the response open while you do downstream work increases your timeout risk and slows the retry feedback loop. - Keep response bodies small. Unioo records the full response body on each attempt; large responses are noisy without adding signal.
Retries
Failed deliveries are retried 10 times in total. There is no fixed schedule a receiver can depend on — assume at-least-once delivery and expect that a delivery you've already processed may be sent again, especially around transient failures (your service restarting, a brief 502, a network blip).
A pragmatic receiver loop:
- Authenticate the request.
- Parse the body and read
eventId. - Check whether this
eventIdhas already been processed. If yes, return200 OKand stop. - Otherwise, enqueue the event for processing and record
eventIdas in-flight. - Return
200 OK.
Step 3 is the idempotency guard — without it, retries will cause duplicate side effects on your side.
No ordering and stateful reads
Two events for the same entity can arrive in any order. For example, REPORT_REVIEW_PROVISIONALLY_APPROVED and REPORT_REVIEW_APPROVED for the same review may arrive in reverse order if the first delivery is retried after the second is sent.
If your processing depends on the entity's current state, do not derive that state from the sequence of webhook events. Instead, on receipt of any event for an entity, query the entity over the GraphQL API and act on what the server returns. This makes your receiver robust against both reorderings and missed events.
Inactive subscriptions
When a subscription is deactivated, Unioo does not call your receiver. Your receiver will simply stop seeing requests; there is no "deactivation" callback.