Skip to main content

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" }
}
FieldTypeNotes
typestringThe event type, e.g. REPORT_PUBLISHED. Always UPPER_SNAKE_CASE.
eventIdUUIDUnique identifier for this delivery. Use it as the idempotency key.
payloadobjectGroup-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 after REPORT_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/json
  • X-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 OK immediately. 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:

  1. Authenticate the request.
  2. Parse the body and read eventId.
  3. Check whether this eventId has already been processed. If yes, return 200 OK and stop.
  4. Otherwise, enqueue the event for processing and record eventId as in-flight.
  5. 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.