Skip to content

Webhooks

Receive real-time notifications when billing events occur — invoices created, payments received, subscriptions changed, and more. You register an HTTPS endpoint; Invora POSTs a signed JSON payload to it whenever a subscribed event fires.

flowchart LR
  E[Event occurs] --> D[Invora POSTs signed payload]
  D --> V[You verify the signature]
  V --> P[You process + return 2xx]
  P -.->|non-2xx or timeout| R[Retry with backoff]

Base URLs & auth

Webhook management lives under /api/billing/v2/webhook-endpoints on the gateway:

Environment Base URL
Production https://gateway.invora.app
Staging https://stg-gateway.invora.app

All management calls carry a bearer token ($TOKEN) — see Authentication.

Register an endpoint

curl -X POST https://gateway.invora.app/api/billing/v2/webhook-endpoints \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order service",
    "webhookUrl": "https://your-server.com/webhooks/invora",
    "eventTypes": ["EVENT_TYPE_INVOICE_CREATED", "EVENT_TYPE_INVOICE_PAYMENT_STATUS_UPDATED", "EVENT_TYPE_SUBSCRIPTION_STARTED"],
    "signatureAlgo": "WEBHOOK_ENDPOINT_SIGNATURE_ALGO_HMAC"
  }'
Response
{
  "webhookEndpoint": {
    "id": "01963f504d7f70008e5e0bd3f629ffe0",
    "name": "Order service",
    "webhookUrl": "https://your-server.com/webhooks/invora",
    "organization": { "id": "9c2b1f7a4e8d4c1fa0b35d6e7f809a12" }
  },
  "hmacKey": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
}

Subscribe to specific event types, or to EVENT_TYPE_ALL for everything. signatureAlgo takes the full enum identifier (WEBHOOK_ENDPOINT_SIGNATURE_ALGO_HMAC / ..._JWT), not the bare suffix — see enum encoding.

hmacKey is returned exactly once — store it now

For an HMAC endpoint the response carries a freshly generated hmacKey. This is the only time it is ever returned: Get and List never expose it, and there is no "show secret" call. If you lose it, your only route back is rotating it, which invalidates the old one. JWT endpoints get no hmacKey — they are signed with a shared cluster RS256 key, so there is nothing per-endpoint to hand back.

The response does not echo signatureAlgo or eventTypes

BillingWebhookEndpoint — the read model returned by Create, Get, List and Update — carries only id, name, webhookUrl, organization and the timestamps. Record the algorithm you chose on your side; you cannot read it back from the API today (tracked in invora-backend#228).

Environment availability — per-endpoint secret lifecycle

Everything about per-endpoint HMAC secrets on this page — the one-time hmacKey in the Create response, the rotate-signing-secret route, and Update generating a key when an endpoint resolves to HMAC — landed recently (invora-backend!435). On earlier builds, Create returns no hmacKey, the rotate route does not exist, and an HMAC endpoint never obtains a secret — so every delivery for it dead-letters unsigned on the first attempt (see signing failures). Confirm your environment is on a build that includes invora-backend!435 before creating HMAC endpoints; until then, use JWT (the default).

Choosing an algorithm

signatureAlgo Signature When to use
WEBHOOK_ENDPOINT_SIGNATURE_ALGO_JWT RS256 JWT signed with Invora's shared webhook key The default. Also what you get if you omit signatureAlgo entirely, or send ..._UNSPECIFIED. Requires Invora's public key to verify — see the caveat below.
WEBHOOK_ENDPOINT_SIGNATURE_ALGO_HMAC HMAC-SHA256 with a per-endpoint secret Choose this if you want a shared secret you hold yourself — no Invora public key needed to verify. Requires a build with invora-backend!435 (see the environment-availability note above); on earlier builds an HMAC endpoint has no secret and every delivery dead-letters.

Omitting signatureAlgo selects JWT, not HMAC

WEBHOOK_ENDPOINT_SIGNATURE_ALGO_UNSPECIFIED (the proto3 default for an unset field) maps to JWT. If you want HMAC you must ask for it explicitly, on Create or on a later Update.

Managing endpoints

Operation Method & path
Create POST /api/billing/v2/webhook-endpoints
Get GET /api/billing/v2/webhook-endpoints/{id}
List POST /api/billing/v2/webhook-endpoints/list
Update PUT /api/billing/v2/webhook-endpoints/{id}
Delete POST /api/billing/v2/webhook-endpoints/delete
Rotate signing secret POST /api/billing/v2/webhook-endpoints/{id}/rotate-signing-secret

Rotating the signing secret

curl -X POST https://gateway.invora.app/api/billing/v2/webhook-endpoints/01963f504d7f70008e5e0bd3f629ffe0/rotate-signing-secret \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
Response
{ "hmacKey": "9WQ0ZXN0X3JvdGF0ZWRfa2V5XzMyX2J5dGVzX2hlcmU=" }

Generates a new 32-byte random secret, replaces the old one immediately, and returns it once. Deliveries signed after this call use the new secret; there is no overlap window, so swap it in atomically on your side.

Rotation is HMAC-only: calling it on a JWT endpoint fails with FAILED_PRECONDITION, because JWT endpoints have no per-endpoint secret.

Update never rotates your key

PUT /api/billing/v2/webhook-endpoints/{id} will generate a secret if the endpoint resolves to HMAC and does not have one yet (including when you flip an existing JWT endpoint to HMAC) — but it never replaces an existing one, so renaming an endpoint cannot silently invalidate your secret. Update does not return the secret either; after flipping an endpoint to HMAC, call rotate-signing-secret to obtain a key you can actually use.

Payload structure

Every delivery carries three top-level fields — webhookType, createdAt, objectType — plus a oneof payload: exactly one of invoice, creditNote, subscription, payment, customer, wallet, walletTransaction, fee, paymentRequest, or eventError.

Delivery body
{
  "webhookType": "invoice.created",
  "createdAt": "2026-04-28T14:30:00Z",
  "objectType": "invoice",
  "invoice": {
    "id": "01963e21-0e46-7000-8d3a-c7f9b2e15a4c",
    "number": "INV-2026-042",
    "status": "INVOICE_STATUS_TYPE_FINALIZED",
    "paymentStatus": "INVOICE_PAYMENT_STATUS_TYPE_PENDING",
    "currency": "SAR",
    "totalAmountCents": { "units": 115000, "nanos": 0 },
    "customer": { "id": "01963f70-6f91-7000-ae70-2df51b4b11a2", "externalId": "your-customer-id", "name": "Acme Corp" }
  }
}

Monetary fields are exact decimals ({units, nanos}); enum fields carry their full identifier — see gRPC & JSON transcoding.

Billing events only

Webhooks carry billing events — invoices, credit notes, subscriptions, payments, customers, wallets, fees. There is no document-lifecycle or e-invoicing event: the EventType enum has no ZATCA, UBL or regulation variant, and neither does the payload oneof. To track a document's clearance/reporting status, poll GET /api/v2/regulations/documents/{documentKey} instead.

Request headers

Header Value
X-Invora-Signature The signature over the raw request body — a base64 digest for HMAC, a complete JWT for JWT.
X-Invora-Signature-Algorithm The algorithm token, lowercase: jwt or hmac.
X-Invora-Unique-Key A stable per-event idempotency key — dedupe on this.
Content-Type application/json (the body is UTF-8 encoded)

Verifying signatures

What is signed

The signature covers the raw request body bytes, exactly as received — nothing else. There is no timestamp prefix, no canonicalisation, and no re-serialisation. In particular this is not the Stripe t=…,v1=… construction.

Capture the raw body before parsing it

Verify against the bytes your framework received, not against JSON.stringify(req.body). Re-serialising a parsed body changes key order and whitespace and will break every signature. In Express use express.raw({ type: 'application/json' }); in Flask use request.get_data(); in ASP.NET Core enable request buffering and read the stream.

HMAC-SHA256

Sent when X-Invora-Signature-Algorithm: hmac.

X-Invora-Signature = base64( HMAC-SHA256( key = utf8_bytes(signing secret), message = raw body bytes ) )

Two details break naive implementations:

The signature is base64, never hex

Standard base64, with = padding and no line breaks — e.g. HT4Di2q/ZPoLA/xyMuzVE6hcck6BNpPV/TFd7oNpfpk=. A digest('hex') / .hexdigest() comparison fails 100% of the time.

The secret is used as a literal string — do not base64-decode it

The secret Invora gives you is itself base64 text (32 random bytes, base64-encoded). The HMAC key is the UTF-8 bytes of that 44-character string, not the 32 bytes it decodes to. Pass it to your HMAC function exactly as you received it.

Compare in constant time, and check lengths first — crypto.timingSafeEqual throws on unequal-length buffers rather than returning false.

const crypto = require('node:crypto');

// rawBody: Buffer — the exact bytes received, before JSON parsing.
// signatureHeader: the X-Invora-Signature header value.
// signingSecret: the hmacKey string, used verbatim (never base64-decoded).
function verifyHmacSignature(rawBody, signatureHeader, signingSecret) {
  const expected = crypto
    .createHmac('sha256', Buffer.from(signingSecret, 'utf8'))
    .update(rawBody)
    .digest('base64');

  const received = Buffer.from(signatureHeader ?? '', 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return (
    received.length === computed.length &&
    crypto.timingSafeEqual(received, computed)
  );
}
import base64
import hashlib
import hmac

def verify_hmac_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
    """raw_body: the exact bytes received, before JSON parsing.
    signing_secret: the hmacKey string, used verbatim (never base64-decoded)."""
    digest = hmac.new(signing_secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode("ascii")
    return hmac.compare_digest(signature_header or "", expected)

Both samples accept this vector, produced by the production signing code:

Test vector
secret     AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
body       {"webhookType":"invoice.created","createdAt":"2026-04-28T14:30:00Z","objectType":"invoice","invoice":{"id":"01963e21-0e46-7000-8d3a-c7f9b2e15a4c","number":"INV-2026-042"}}
signature  HT4Di2q/ZPoLA/xyMuzVE6hcck6BNpPV/TFd7oNpfpk=

JWT (RS256)

Sent when X-Invora-Signature-Algorithm: jwt. This is the default algorithm.

X-Invora-Signature holds a complete RS256 JWT — not a bare digest — with exactly two claims:

Claim Value
data The raw request body, as a JSON string
iss The webhook issuer — https://invora.app unless your deployment overrides it. Read iss off a delivered token once, then pin that value.

The JOSE header is exactly {"alg":"RS256","typ":"JWT"}. There is deliberately no iat, exp or nbf, so verification never depends on clock skew — and, correspondingly, the token carries no freshness of its own. Verification is therefore three steps, and all three matter:

  1. Verify the RS256 signature against Invora's webhook public key.
  2. Check iss matches the expected issuer.
  3. Check the data claim equals the raw body you received — this is what binds the token to this request. Skip it and any previously captured token authenticates any body.

The webhook public key is not published at a JWKS URL yet

This is a different keypair from the Zitadel OIDC signing keys — do not use the OIDC JWKS endpoint, which will never verify a webhook signature. The webhook RS256 key is a standalone, cluster-wide key whose public half has no published endpoint today (tracked in invora-backend#228); request it from support to verify JWT deliveries. If you need self-service verification now, register your endpoint with WEBHOOK_ENDPOINT_SIGNATURE_ALGO_HMAC instead.

const crypto = require('node:crypto');

// rawBody: Buffer — the exact bytes received, before JSON parsing.
// token: the X-Invora-Signature header value (a full JWT).
// publicKeyPem: Invora's webhook RS256 public key, PEM-encoded.
function verifyJwtSignature(rawBody, token, publicKeyPem, expectedIssuer) {
  const parts = (token ?? '').split('.');
  if (parts.length !== 3) return false;
  const [header, claims, signature] = parts;

  const signatureOk = crypto.verify(
    'RSA-SHA256',
    Buffer.from(`${header}.${claims}`, 'ascii'),
    publicKeyPem,
    Buffer.from(signature, 'base64url'),
  );
  if (!signatureOk) return false;

  const { alg } = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
  if (alg !== 'RS256') return false;

  const payload = JSON.parse(Buffer.from(claims, 'base64url').toString('utf8'));
  if (payload.iss !== expectedIssuer) return false;

  // Binds the token to THIS request body.
  return Buffer.from(payload.data, 'utf8').equals(rawBody);
}
import jwt  # PyJWT

def verify_jwt_signature(raw_body: bytes, token: str, public_key_pem: str, expected_issuer: str) -> bool:
    """raw_body: the exact bytes received, before JSON parsing.
    token: the X-Invora-Signature header value (a full JWT)."""
    try:
        claims = jwt.decode(
            token,
            public_key_pem,
            algorithms=["RS256"],
            issuer=expected_issuer,
            # The token carries no iat/exp/nbf by design.
            options={"verify_exp": False, "verify_aud": False, "require": ["iss"]},
        )
    except jwt.InvalidTokenError:
        return False
    # Binds the token to THIS request body.
    return claims.get("data", "").encode("utf-8") == raw_body

Delivery guarantees

At-least-once, unordered

Events are delivered at least once and may arrive out of order (especially during retries). Make your endpoint idempotent (dedupe on X-Invora-Unique-Key) and use createdAt to discard events older than your last-processed state for a resource.

  • Timeout: your endpoint must respond 2xx within 30 seconds. If processing takes longer, acknowledge immediately and process asynchronously.
  • Idempotency key: X-Invora-Unique-Key. Store processed keys and skip duplicates — the same key always carries the same payload.

Retry policy

A non-2xx response or a timeout is retried with exponential backoff — 5 attempts total:

Attempt Delay before it
1 immediate
2 30 s
3 60 s
4 120 s
5 240 s

After the 5th failed attempt the delivery is dead-lettered and its record's status becomes WEBHOOK_STATUS_FAILED.

A signing failure is not retried

If the endpoint is misconfigured such that no signature can be computed (an HMAC endpoint with no secret, or a missing RS256 key), the delivery is dead-lettered on the first attempt rather than retried — that failure is not transient.

RetryWebhook does not re-deliver

Despite the name, POST …/webhooks/{id}/retry reads the stored delivery record and returns it. The delivery log does not persist the original payload, so nothing can be re-POSTed from it. To recover a dead-lettered delivery, refetch the affected resource through its own API. Tracked in invora-backend#228.

Delivery history

Operation Method & path
List deliveries POST /api/billing/v2/webhook-endpoints/{webhookEndpointId}/webhooks/list
Get a delivery GET /api/billing/v2/webhook-endpoints/{webhookEndpointId}/webhooks/{id}

Each delivery record carries webhookType, httpStatus, the (truncated) response body, retries, and timestamps. retries counts attempts beyond the first, so a delivery that succeeded first time reports 0. Filter the list by event type, HTTP status, delivery status, or a fromDate / toDate bound — one condition per request.

Event catalog

Event types are full enum identifiers prefixed EVENT_TYPE_ (e.g. EVENT_TYPE_INVOICE_CREATED); EVENT_TYPE_ALL subscribes to everything. The most commonly used:

Area Events
Invoice INVOICE_CREATED, INVOICE_DRAFTED, INVOICE_GENERATED, INVOICE_ONE_OFF_CREATED, INVOICE_VOIDED, INVOICE_PAYMENT_STATUS_UPDATED, INVOICE_PAYMENT_FAILURE, INVOICE_PAYMENT_OVERDUE
Subscription SUBSCRIPTION_STARTED, SUBSCRIPTION_TERMINATED, SUBSCRIPTION_UPDATED, SUBSCRIPTION_TRIAL_ENDED, SUBSCRIPTION_USAGE_THRESHOLD_REACHED
Payment PAYMENT_SUCCEEDED, PAYMENT_REQUIRES_ACTION, PAYMENT_RECEIPT_CREATED, PAYMENT_REQUEST_CREATED
Customer CUSTOMER_CREATED, CUSTOMER_UPDATED, CUSTOMER_CHECKOUT_URL_GENERATED
Credit note & wallet CREDIT_NOTE_CREATED, WALLET_CREATED, WALLET_DEPLETED_ONGOING_BALANCE, WALLET_TRANSACTION_CREATED
Other ALERT_TRIGGERED, FEE_CREATED, PLAN_CREATED / PLAN_UPDATED / PLAN_DELETED, DUNNING_CAMPAIGN_FINISHED

The complete set is the EventType enum — 60 event types beyond EVENT_TYPE_UNSPECIFIED, one of which (EVENT_TYPE_ALL) is a subscription wildcard rather than a fireable event. The API reference lists them all.