> ## Documentation Index
> Fetch the complete documentation index at: https://university.triggr-app.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Register a webhook

> An https URL on a public host, for a set of events. The signing secret is returned ONCE — record it now: it is never stored by us, so replaying this call with the same `Idempotency-Key` returns `"secret": null` rather than repeating it (rotate the secret if you lost it). Deliveries are POSTed within about a minute and retried on 1m / 5m / 30m / 2h / 12h.

Every delivery carries four headers: `X-Triggr-Signature` (see below), `X-Triggr-Event` (the event name), `X-Triggr-Delivery-Id` (the delivery row's id) and `User-Agent: Triggr-Webhooks/1.0`. The body is a `WebhookEnvelope`.

Two ids, and they mean different things. The envelope's `id` identifies the EVENT: it is unchanged across retries and across a redelivery (POST …/redeliver re-posts the original payload bytes), so it is your dedupe key. `X-Triggr-Delivery-Id` identifies the DELIVERY ROW: it is the same on every retry attempt of that delivery (attempts 1 to 5 all carry it), changes on a redelivery, and matches a row in GET …/deliveries — use it to correlate with the delivery history and in support conversations.

Verify `X-Triggr-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + rawBody)>`, rejecting a `t` older than 5 minutes. The header may carry MORE THAN ONE `v1` — for 24 hours after a secret rotation (POST …/rotate-secret) each delivery is signed with the new secret and the previous one — so accept it if ANY `v1` matches.

A webhook whose deliveries keep exhausting their retries after 25 consecutive failures is disabled (re-enable it with POST …/enable once the endpoint is fixed); revoking the key that registered a webhook disables it too. Delivered and failed history is kept for 30 days. An event is delivered at most once per webhook unless you ask for it again with …/redeliver.

**Catalogue changes** — subscribe to these instead of re-reading the lists: `form.created` / `form.updated` / `form.deleted`, `client.created` / `client.updated` / `client.deleted`, and `core_form.updated`. They fire for a change made in any way — the Triggr app, this API, or Triggr's AI assistant — and are sent typically within 30 seconds of the change (at most about 90 seconds), then retried on the schedule above. `data` is the object exactly as the list endpoint returns it now (`Form`, `Client` or `CoreForm`); for a `*.deleted` event it is `{ "id", "deletedAt" }`. `form.updated` fires on any change to a form, including its fields (compare `updatedAt`); `client.updated` fires only when something in the `Client` object changed; `core_form.updated` when its enabled / availability / homepage settings or a client override changed. Core forms are a fixed catalogue, so they are never created or deleted. Each form and workflow also carries `updatedAt`, so after an outage longer than the retry schedule one list call tells you what changed.



## OpenAPI

````yaml /api-reference/openapi.json post /api/v1/webhooks
openapi: 3.0.3
info:
  description: >-
    The API an MSP's own portal (or a partner acting for it) uses to wrap
    Triggr: list clients, forms, workflows, executions and Core requests; embed
    forms as a signed-in user; run Core forms natively; act on approvals and
    manual steps; configure Core workflows; and receive events by webhook.


    Authentication: every request carries an organisation API key as
    `Authorization: Bearer tpk_…` (issued on the API Keys settings page; shown
    once). Every key can read the catalogue and mint form sessions; `core:run`,
    `core:configure` and `executions:manage` are opt-in scopes chosen when the
    key is created — see `x-triggr-scopes` and each operation's
    `x-triggr-scope`.


    Rate limits: 600 requests/min per key (bursts to 200) for reads and 60/min
    (bursts to 30) for writes; form-session mints have their own 120/min (bursts
    to 120). Authenticated responses carry `X-RateLimit-Limit` /
    `X-RateLimit-Remaining` (exposed to browsers); a 429 carries `Retry-After`.


    Paging: lists take `limit` (default 50, max 100) and `offset` and return
    `total` + `hasMore`; `since` (RFC 3339) is accepted where an operation
    declares it (executions, Core requests).


    Idempotency is OPTIONAL and opt-in: send nothing and every endpoint behaves
    exactly as before — existing integrations need change nothing. The
    operations that start or decide something (run a Core form, approve /
    reject, complete a manual step, cancel / retry an execution, register a
    webhook) additionally accept an `Idempotency-Key` header (1–255 characters;
    use a UUID per request). Retry a timed-out call with the same key and the
    same method, path and body within 24 hours and you get the first response
    back — status and body — flagged `Idempotent-Replayed: true`, instead of a
    second run. The same key with a different request is refused with 422; a
    repeat while the first is still in flight is a 409; a 5xx is never stored,
    so that retry runs for real. Keys are scoped to the API key that sent them.


    One-time values are the exception, and deliberately so: a webhook signing
    `secret` and a Core run's `secret` (the new starter's temporary password)
    are never written to our store, so a REPLAYED response carries `"secret":
    null` and an `idempotentReplayNote` saying where to get a fresh one. Record
    those from the original response — a replay cannot give them back.


    Webhooks: a delivery is a POST carrying `X-Triggr-Signature`,
    `X-Triggr-Event`, `X-Triggr-Delivery-Id` and `User-Agent:
    Triggr-Webhooks/1.0`, with a `WebhookEnvelope` body. Mind the two ids: the
    envelope's `id` names the EVENT and is unchanged across retries and
    redeliveries, so it is your dedupe key; `X-Triggr-Delivery-Id` names the
    DELIVERY ROW — the same on every retry of that delivery, new on a redelivery
    — and matches a row in the Deliveries list. Verify the signature by
    accepting the header if any `v1` matches your secret — there are two for 24
    hours after a rotation.


    ## Embedding


    **Which forms can be embedded.** A form can be embedded in your portal
    exactly when its `embeddable` field is true (every form in `GET
    /api/v1/forms`, `GET /api/v1/forms/{formId}` and the Core forms list and
    detail carries it). By auth type: `microsoft` and `google` — yes, as the
    signed-in user, through a form session; `none` (public) — yes, through a
    form session that attributes the submission to your portal user and client,
    or plainly from `shareUrl` (anonymous); `password` and `internal` — no: a
    session request is refused with `form_session_unsupported`, and neither can
    sign a visitor in inside a third-party frame. Core forms are embeddable when
    `public` (Microsoft sign-in). Branch on `embeddable`, not on your own list
    of auth types.


    **When a session ends.** Inside your frame, the form page tells your page
    when its embedded session can't be used, with `window.parent.postMessage({
    type: "triggr:form-session", state }, "*")` — exactly those two fields, no
    token. `state` is `"error"` when the handle couldn't be redeemed (already
    used, expired, or rate-limited past its retry) and `"expired"` when the
    signed-in session lapsed (noticed on reload or submit — and, on a Core form,
    on a lookup) — at most once per lapse. Nothing is posted when the page isn't
    framed. Listen for it, check `event.origin` equals your stored embed origin
    (`formsOrigin` / `coreFormsOrigin`), then mint a fresh session and replace
    the iframe's `src` with its `embedUrl`; without that, the visitor is left on
    a "reopen the form" message.


    **The embed host.** Every `embedUrl` and `shareUrl` begins with the
    organisation's canonical embed origin: `scheme://host[:port]` — `https`,
    lower-case host, no path, no trailing slash, no default port. It is the same
    for every form and session and does not change between releases — only if
    you change your forms domain — and `GET /api/v1/me` returns it as
    `formsOrigin` (Studio) and `coreFormsOrigin` (Core). Store that value as
    your embed host, and before framing a session compare it byte-for-byte with
    the ORIGIN of the returned `embedUrl` (`new URL(embedUrl).origin`; in .NET
    `new Uri(embedUrl).GetLeftPart(UriPartial.Authority)`). Worked example —
    stored embed host `https://forms.acme-msp.com`; `embedUrl`
    `https://forms.acme-msp.com/forms/org_2Ab3/9c8d…?formSession=3f9a…` → origin
    `https://forms.acme-msp.com` → match. With no custom forms domain the origin
    is `https://app.triggr-app.com` (Studio) or `https://core.triggr-app.com`
    (Core). If you store a bare host instead (`forms.acme-msp.com`), compare it
    with the URL's host component, lower-cased.


    Errors are `{ "error": "…" }`: 400 your request, 401 the key, 403 scope or
    not your client, 404 not found, 412 a stale If-Match, 429 rate limit, 503 a
    dependency such as the on-prem agent. The two form-session endpoints instead
    answer `{ "error": { "code", "message", "retryable" } }` (SessionError).
    Timestamps are RFC 3339 UTC.
  title: Triggr Public API
  version: 1.4.0
servers:
  - url: https://api.triggr-app.com
security:
  - ApiKeyAuth: []
tags:
  - description: Who this key is, and the clients every other endpoint scopes by.
    name: Organisation
  - description: >-
      Studio forms with shareable embed URLs, and form sessions that embed a
      `microsoft` or `google` form as an already-signed-in user. Branch on each
      form's `embeddable` (see Embedding): `microsoft` and `google` forms embed
      as the signed-in user, `none` forms embed with a session attributing the
      submission to your portal user (or plainly from `shareUrl`), and
      `password` / `internal` forms cannot be embedded.
    name: Studio forms
  - description: >-
      The Studio workflow catalogue and a status feed of runs: what ran, for
      which client, how it ended. Outcome only — never parameters, step context
      or logs.
    name: Workflows & executions
  - description: >-
      Core forms (onboarding, offboarding, …) with their configuration status,
      embedded sessions for the public ones, and every request submitted through
      them with live status.
    name: Core forms
  - description: >-
      Render and submit a Core form in your own UI as a technician. Scope
      `core:run`.
    name: Core run
  - description: >-
      Read and write Core configuration. A form has one base config and optional
      per-client overrides; writes merge (omitted keeps, formConfig/execConfig
      replace as units, null clears, `inherit` clears a client's availability);
      read the `etag` and send If-Match to write atomically. Scope
      `core:configure`.
    name: Core configuration
  - description: >-
      The work waiting for a person. Read with any key; decide with scope
      `executions:manage`, naming the actor.
    name: Approvals & manual steps
  - description: >-
      Signed event delivery to your endpoint, so you don't poll. Register a URL,
      verify `X-Triggr-Signature` (accept the header if any `v1` matches — there
      are two for 24 hours after a secret rotation), inspect and re-send
      deliveries.
    name: Webhooks
  - description: This document.
    name: Meta
paths:
  /api/v1/webhooks:
    post:
      tags:
        - Webhooks
      summary: Register a webhook
      description: >-
        An https URL on a public host, for a set of events. The signing secret
        is returned ONCE — record it now: it is never stored by us, so replaying
        this call with the same `Idempotency-Key` returns `"secret": null`
        rather than repeating it (rotate the secret if you lost it). Deliveries
        are POSTed within about a minute and retried on 1m / 5m / 30m / 2h /
        12h.


        Every delivery carries four headers: `X-Triggr-Signature` (see below),
        `X-Triggr-Event` (the event name), `X-Triggr-Delivery-Id` (the delivery
        row's id) and `User-Agent: Triggr-Webhooks/1.0`. The body is a
        `WebhookEnvelope`.


        Two ids, and they mean different things. The envelope's `id` identifies
        the EVENT: it is unchanged across retries and across a redelivery (POST
        …/redeliver re-posts the original payload bytes), so it is your dedupe
        key. `X-Triggr-Delivery-Id` identifies the DELIVERY ROW: it is the same
        on every retry attempt of that delivery (attempts 1 to 5 all carry it),
        changes on a redelivery, and matches a row in GET …/deliveries — use it
        to correlate with the delivery history and in support conversations.


        Verify `X-Triggr-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "."
        + rawBody)>`, rejecting a `t` older than 5 minutes. The header may carry
        MORE THAN ONE `v1` — for 24 hours after a secret rotation (POST
        …/rotate-secret) each delivery is signed with the new secret and the
        previous one — so accept it if ANY `v1` matches.


        A webhook whose deliveries keep exhausting their retries after 25
        consecutive failures is disabled (re-enable it with POST …/enable once
        the endpoint is fixed); revoking the key that registered a webhook
        disables it too. Delivered and failed history is kept for 30 days. An
        event is delivered at most once per webhook unless you ask for it again
        with …/redeliver.


        **Catalogue changes** — subscribe to these instead of re-reading the
        lists: `form.created` / `form.updated` / `form.deleted`,
        `client.created` / `client.updated` / `client.deleted`, and
        `core_form.updated`. They fire for a change made in any way — the Triggr
        app, this API, or Triggr's AI assistant — and are sent typically within
        30 seconds of the change (at most about 90 seconds), then retried on the
        schedule above. `data` is the object exactly as the list endpoint
        returns it now (`Form`, `Client` or `CoreForm`); for a `*.deleted` event
        it is `{ "id", "deletedAt" }`. `form.updated` fires on any change to a
        form, including its fields (compare `updatedAt`); `client.updated` fires
        only when something in the `Client` object changed; `core_form.updated`
        when its enabled / availability / homepage settings or a client override
        changed. Core forms are a fixed catalogue, so they are never created or
        deleted. Each form and workflow also carries `updatedAt`, so after an
        outage longer than the retry schedule one list call tells you what
        changed.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            example:
              events:
                - execution.completed
                - execution.failed
                - approval.requested
              url: https://portal.example.com/hooks/triggr
            schema:
              properties:
                description:
                  type: string
                events:
                  items:
                    enum:
                      - '*'
                      - execution.completed
                      - execution.failed
                      - approval.requested
                      - approval.decided
                      - approval.expired
                      - manual_step.created
                      - manual_step.completed
                      - manual_step.cancelled
                      - core.request.submitted
                      - form.created
                      - form.updated
                      - form.deleted
                      - core_form.updated
                      - client.created
                      - client.updated
                      - client.deleted
                    type: string
                  type: array
                url:
                  format: uri
                  type: string
              required:
                - url
                - events
              type: object
        required: true
      responses:
        '201':
          content:
            application/json:
              example:
                secret: whsec_3Jd8Kq2mXv7pR4tYw1Zn6Bc5Ha0Sf9Le
                signature: >-
                  X-Triggr-Signature: t=<unix>,v1=hex(HMAC-SHA256(secret, t +
                  "." + body))
                webhook:
                  active: true
                  consecutiveFailures: 0
                  createdAt: '2026-09-01T08:30:00Z'
                  description: Acme portal
                  events:
                    - execution.completed
                    - execution.failed
                    - approval.requested
                  id: e2b7c4d9-1f8a-4c36-b5e0-7a9d2f4c8b13
                  lastDeliveryAt: null
                  url: https://portal.example.com/hooks/triggr
              schema:
                properties:
                  idempotentReplayNote:
                    description: >-
                      Present only on an Idempotency-Key replay whose original
                      response carried a one-time credential: says that the
                      value is shown only in the original response and how to
                      issue a fresh one
                    type: string
                  secret:
                    description: >-
                      The signing secret, shown ONCE. null on an Idempotency-Key
                      replay — see idempotentReplayNote
                    nullable: true
                    type: string
                  signature:
                    type: string
                  webhook:
                    $ref: '#/components/schemas/Webhook'
                type: object
          description: Created
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Invalid request — the message names the problem
        '401':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Missing, invalid, revoked, or expired API key
        '409':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: >-
            A request with this Idempotency-Key is still in progress — retry
            shortly
        '422':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Idempotency-Key was already used with a different request
        '429':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Rate limit exceeded for this key — see Retry-After
      security:
        - ApiKeyAuth: []
      x-codeSamples:
        - label: curl
          lang: curl
          source: |-
            # Idempotency-Key is optional: send one to make a retry safe.
            curl -X POST "https://api.triggr-app.com/api/v1/webhooks" \
              -H "Authorization: Bearer $TRIGGR_API_KEY" \
              -H "Idempotency-Key: $(uuidgen)" \
              -H "Content-Type: application/json" \
              -d '{
              "url": "https://portal.example.com/hooks/triggr",
              "events": [
                "execution.completed",
                "execution.failed",
                "approval.requested"
              ]
            }'
        - label: Node
          lang: node
          source: >-
            // Idempotency-Key is optional: send one to make a retry safe.

            const res = await
            fetch("https://api.triggr-app.com/api/v1/webhooks", {
              method: "POST",
              headers: {
                "Authorization": `Bearer ${process.env.TRIGGR_API_KEY}`,
                "Content-Type": "application/json",
                "Idempotency-Key": crypto.randomUUID(),
              },
              body: JSON.stringify({
                "url": "https://portal.example.com/hooks/triggr",
                "events": [
                  "execution.completed",
                  "execution.failed",
                  "approval.requested"
                ]
              }),
            });

            if (!res.ok) throw new Error(`Triggr ${res.status}: ${await
            res.text()}`);

            const data = await res.json();
        - label: PowerShell
          lang: powershell
          source: >-
            # Idempotency-Key is optional: send one to make a retry safe.

            $headers = @{
              Authorization = "Bearer $env:TRIGGR_API_KEY"
              'Idempotency-Key' = [guid]::NewGuid().ToString()
            }

            $body = @'

            {
              "url": "https://portal.example.com/hooks/triggr",
              "events": [
                "execution.completed",
                "execution.failed",
                "approval.requested"
              ]
            }

            '@

            Invoke-RestMethod -Method Post -Uri
            'https://api.triggr-app.com/api/v1/webhooks' -Headers $headers
            -ContentType 'application/json' -Body $body
components:
  parameters:
    IdempotencyKey:
      description: >-
        OPTIONAL — omit it and this operation behaves exactly as it always has.
        A unique value (a UUID) you choose per request. A retry with the same
        key and the same method, path and body within 24 hours gets the first
        response replayed (status and body, with `Idempotent-Replayed: true`)
        instead of running again; the same key with a different request is
        refused with 422. A 5xx is not stored, so that retry runs. A one-time
        credential is never stored: on a replay any `secret` is null, with an
        `idempotentReplayNote` explaining it.
      in: header
      name: Idempotency-Key
      required: false
      schema:
        maxLength: 255
        minLength: 1
        type: string
  schemas:
    Webhook:
      properties:
        active:
          type: boolean
        consecutiveFailures:
          type: integer
        createdAt:
          format: date-time
          type: string
        description:
          type: string
        disabledReason:
          type: string
        events:
          items:
            type: string
          type: array
        id:
          type: string
        lastDeliveryAt:
          format: date-time
          type: string
        lastError:
          type: string
        url:
          format: uri
          type: string
      required:
        - id
        - url
        - events
        - active
        - consecutiveFailures
        - createdAt
      type: object
    Error:
      properties:
        error:
          type: string
      required:
        - error
      type: object
  securitySchemes:
    ApiKeyAuth:
      bearerFormat: tpk_…
      description: >-
        Organisation API key. Scoped operations carry x-triggr-scope; the scopes
        themselves are described in x-triggr-scopes.
      scheme: bearer
      type: http

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.