Inovacc Developer

Guides

Webhooks

A webhook is the way Events delivers to a server you run: every event that matches a subscription arrives at your HTTPS endpoint as a signed POST. This guide covers creating a webhook subscription, the request your endpoint receives, verifying its signature, how retries and dead letters work, and rotating the signing secret without dropping a delivery.

What a webhook subscription is

A subscription says which events a receiver wants and how they reach it. A webhook subscription has a list of type patterns and a delivery block with "mode": "webhook" and your url. From then on, each event published in the workspace whose type matches one of the patterns is posted to that URL, once per subscription.

Delivery is at least once. Under normal operation each event arrives once; a retry, a slow answer or a lost connection can make it arrive again. Your receiver is therefore built to accept a duplicate harmlessly, keyed on the event id.

The URL must be https, on port 443, with no user name or password in it, no fragment, and a public host: private, loopback, link-local and local-use names are refused when the subscription is created and checked again at every delivery. Redirects are never followed.

Create one

Call POST https://events.inovacc.dev/v1/workspaces/{workspace}/subscriptions with a secret key that has the subscription write permission:

Language

⋮
    JSONExample

    JSON

    {"types": ["order.*"], "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc"}}

    The answer is 201:

    Language

    ⋮
      JSONExample

      JSON

      {"subscription_id": "sub_...", "types": ["order.*"],
       "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc", "secret_ref": "managed"},
       "created_at": "2026-10-06T12:00:00.000Z",
       "signing_secret": "whsec_..."}

      signing_secret is the key your endpoint uses to verify deliveries. It is shown once, in this answer, and never again: listings show "secret_ref": "managed" and nothing more. Store it in your secret manager at once. If it is lost, rotate it (below). A workspace holds at most 50 subscriptions, each with 1 to 20 patterns.

      The delivery request

      Each delivery is:

      POST https://hooks.example.com/inovacc
      content-type: application/json
      x-event-id: evt_01K...
      x-event-type: order.paid
      x-event-timestamp: 1791201600
      x-event-signature: v1=<64 lowercase hex characters>
      
      {"event_id":"evt_01K...","type":"order.paid", ... }
      

      x-event-timestamp is the Unix time in seconds at which the delivery was signed. During a secret rotation, x-event-signature carries one entry per live secret, newest first, separated by commas: v1=<new>,v1=<old>.

      Answer with any 2xx status to confirm the delivery. Anything else (a 4xx, a 5xx, no answer within 10 seconds, a network error) is a failed attempt and will be retried.

      The event envelope

      The body is the event envelope, exactly as subscribers of every mode receive it:

      FieldMeaning
      event_idevt_ followed by 26 characters, assigned by Inovacc when the event was published. Unique per event.
      typeThe event's type, such as order.paid: dotted lowercase words, up to 128 bytes.
      sourceThe application that published the event, taken from its credential.
      timeWhen it was published, RFC 3339 UTC with milliseconds.
      tenantThe organization, account, workspace and, when known, application the event belongs to.
      idempotency_keyThe publisher's key; when it sent none, the event_id.
      dataThe publisher's JSON payload: what happened, usually ids rather than whole objects.

      Read only the fields you need, and ignore fields you do not know.

      Verify the signature

      Verify every delivery before you parse or act on it:

      1. Read x-event-timestamp and the raw body bytes, exactly as received, before any JSON parsing.
      2. Compute HMAC-SHA256(secret, timestamp + "." + body), where secret is the whole whsec_... string as UTF-8, and write it as lowercase hex.
      3. Split x-event-signature on commas and accept the delivery when any v1= entry equals your value, comparing in constant time.
      4. Reject a timestamp more than five minutes away from your clock, so an old delivery cannot be replayed at you.

      Worked example. With the secret example-key-0001, the timestamp 1791201600 and the body {"hello":"world"}, the signed text is 1791201600.{"hello":"world"} and the signature is the 64 hex characters below, printed here in two halves that you join without a space:

      05080ab29a6abfb44033966c7837c2a1
      889fbf1a909f9cbd12b9dd5d12bb2bf5
      

      so the header reads v1= followed by both halves. Run your verifier on this example before you deploy it.

      TypeScript (Node.js 18 or later):

      Language

      ⋮
        TypeScriptExample

        TypeScript

        import { createHmac, timingSafeEqual } from "node:crypto";
        
        export function verifyInovaccWebhook(
          secret: string,
          timestamp: string,
          signatureHeader: string,
          rawBody: Buffer,
          nowSeconds: number = Math.floor(Date.now() / 1000),
        ): boolean {
          const ts = Number(timestamp);
          if (!Number.isInteger(ts) || Math.abs(nowSeconds - ts) > 300) return false;
          const expected = createHmac("sha256", secret)
            .update(`${timestamp}.`)
            .update(rawBody)
            .digest();
          return signatureHeader.split(",").some((entry) => {
            const part = entry.trim();
            if (!part.startsWith("v1=")) return false;
            const given = Buffer.from(part.slice(3), "hex");
            return given.length === expected.length && timingSafeEqual(given, expected);
          });
        }

        Python (3.10 or later):

        Language

        ⋮
          PythonExample

          Python

          import hashlib
          import hmac
          import time
          
          
          def verify_inovacc_webhook(secret: str, timestamp: str, signature_header: str,
                                     raw_body: bytes, now: int | None = None) -> bool:
              try:
                  ts = int(timestamp)
              except ValueError:
                  return False
              now = int(time.time()) if now is None else now
              if abs(now - ts) > 300:
                  return False
              expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body,
                                  hashlib.sha256).hexdigest()
              return any(
                  part.strip().startswith("v1=")
                  and hmac.compare_digest(part.strip()[3:], expected)
                  for part in signature_header.split(",")
              )

          Both functions take the raw body: configure your web framework to hand you the bytes, not a parsed object, on this route.

          Retries and timeouts

          Your endpoint has 10 seconds to answer. A failed attempt is retried after 10 seconds, and the delay doubles on each further failure up to 10 minutes. When the retry cap of 5 is reached, the event moves to your workspace's dead letters. A failure affects only the delivery that failed: other events, and other subscriptions of the same event, keep flowing.

          Because a delivery can be repeated, a 2xx that arrives after your endpoint already processed the event is harmless as long as you deduplicate by x-event-id.

          Rotate the secret

          POST /v1/workspaces/{workspace}/subscriptions/{id}/rotate-secret returns a new secret, once:

          Language

          ⋮
            JSONExample

            JSON

            {"subscription_id": "sub_...", "signing_secret": "whsec_...", "previous_valid_until": "2026-10-07T12:00:00.000Z"}

            Until previous_valid_until (24 hours by default), both secrets sign: each delivery carries v1=<new>,v1=<old>. Deploy the new secret to your receiver within that window; because the verifier accepts any matching entry, nothing is dropped while you switch. Rotating again inside the window keeps every unexpired secret. Rotation applies to webhook subscriptions only (409 wrong_delivery_mode otherwise).

            Dead letters and replay

            GET /v1/workspaces/{workspace}/dead?limit=50 lists dead letters, newest first, with the event, attempts, dead_at, replay_count and last_error:

            last_errorMeaning
            webhook_status_<code>Your endpoint answered that status, for example webhook_status_500.
            webhook_timeoutNo answer within 10 seconds.
            webhook_networkThe connection failed.
            url_destination_blockedThe URL now points somewhere deliveries may not go.
            secret_missingThe subscription's signing secret could not be used.

            Fix the cause, then POST /v1/workspaces/{workspace}/dead/{event_id}/replay. The answer is 202, and the same envelope, with the same event_id, is delivered again to the subscriptions that never received it. Dead letters are kept 30 days.

            Best practices

            • Verify before parsing: compute the signature over the raw bytes, then parse.
            • Make the receiver idempotent: store each processed x-event-id and answer 2xx at once for one you have seen.
            • Answer fast: confirm with a 2xx and do slow work after, from your own job.
            • Reject stale timestamps (more than five minutes off) and keep your server clock synchronised.
            • Keep the secret in a secret manager and rotate it when someone who knew it leaves, or on a schedule.
            • Monitor dead letters and dlq_inflow in GET /stats; replay once the endpoint is healthy.
            • Return 2xx only when you have the event safely: a 2xx ends the retries.

            See also

            Updated 2026-10-10.