Create a webhook subscription

Create, update, pause, and delete a Wingspan V3 webhook subscription, and rotate its signing secret.

This page shows you how to create a webhook subscription so Wingspan pushes events to your server, and how to manage it afterward.

Before you start

You need:

  • An HTTPS endpoint on the public internet that accepts POST requests. It must not redirect.
  • An API token with the platform.webhook:write permission on the Account, Organization, or Person whose events you want.
  • The event types you want. Pick them from Event types or from GET /v3/platform/webhook-event-types.

Build and deploy the endpoint first, including signature verification. Events start arriving as soon as the subscription exists.

Choose a scope

The scope object decides whose events the subscription receives. It takes one of three shapes.

One Account, or an Account and its children:

{ "type": "Account", "id": "Qm4xT8vLp2RzW6nYc0aBd3", "shouldIncludeDescendants": false }

Set shouldIncludeDescendants to true to also receive events for every child Account under it. It's required, so always send it.

An Organization, with or without its Accounts:

{ "type": "Organization", "id": "Zr8cV1nQ5tLw3pXk7mYb2e", "shouldIncludeAccounts": true }

With shouldIncludeAccounts: true you receive Organization events and events for Accounts that belonged to the Organization when the event happened. It never includes Person events.

Yourself, as a Person:

{ "type": "Person", "id": "Pd6sK0wHq4yNf8jLt2xVa9" }

A Person scope only works for your own Person ID.

Each event type can go to certain scope types only. For example, invoice and payable events go to Accounts, and notification events go to Persons. The Sent to column on Event types shows which.

Tip: Put the scope in the request body, not in an X-Wingspan-Account header. Webhook and event endpoints pick their owner from scope, and they reject the header.

Choose events

subscribedEvents is a list of 1 to 500 patterns. Each pattern is one of:

PatternMatches
Invoice.PaidThat one event type.
Invoice.*Every event type for that resource, including ones we publish later.
*Every event type, including ones we publish later.

We reject the request with 422 if a pattern is duplicated, names an event type that isn't available yet, names an event that can't be sent to your scope type, or is a wildcard that matches nothing for your scope.

Wildcards save you from editing the subscription each time we publish a new event, but they also mean your handler receives types it hasn't seen. Ignore types you don't handle and still return 2xx. High-volume resources such as FundsMovement and Invoice can produce many events, so size your endpoint before you subscribe to *.

Step 1: Create the subscription

Call POST /v3/platform/webhooks. Idempotency-Key is required.

curl -X POST https://api.wingspan.app/v3/platform/webhooks \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://hooks.northwindstaffing.example.com/wingspan",
    "subscribedEvents": ["Invoice.Paid", "Invoice.DepositConfirmed", "Payable.*"],
    "scope": {
      "type": "Account",
      "id": "Qm4xT8vLp2RzW6nYc0aBd3",
      "shouldIncludeDescendants": false
    },
    "description": "Accounts payable sync",
    "metadata": { "environment": "production" }
  }'

The response is 201 Created:

{
  "id": "Wb3nR7tYq1LmK5xPz9cVa2",
  "url": "https://hooks.northwindstaffing.example.com/wingspan",
  "description": "Accounts payable sync",
  "status": "Active",
  "configurationGeneration": 1,
  "subscribedEvents": ["Invoice.Paid", "Invoice.DepositConfirmed", "Payable.*"],
  "scope": { "type": "Account", "id": "Qm4xT8vLp2RzW6nYc0aBd3", "shouldIncludeDescendants": false },
  "events": { "createdAt": "2026-09-24T15:02:11Z" },
  "actors": { "createdBy": "Pd6sK0wHq4yNf8jLt2xVa9" },
  "metadata": { "environment": "production" },
  "secret": "whsec_..."
}

Step 2: Store the secret

Important: secret appears in this response only. We never return it again, including on GET. Store it in your secrets manager before you do anything else.

If you lost the response (for example, your client timed out), don't retry with the same Idempotency-Key. A same-key retry returns 409 ResourceConflict with the subscription's URL in the Location header, but it never returns the secret. Instead, rotate the secret with a new key and "immediate": true. That revokes the secret you never saw and gives you a new one.

Step 3: Confirm it works

Trigger one of the events you subscribed to, then check that your endpoint received it. You can see every delivery attempt with GET /v3/platform/webhooks/{webhookId}/deliveries:

curl -G https://api.wingspan.app/v3/platform/webhooks/Wb3nR7tYq1LmK5xPz9cVa2/deliveries \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  --data-urlencode "page[size]=20"
// (trimmed)
{
  "data": [
    {
      "id": "Dl5vB9mQ2xRt7kNw1pZc4s",
      "eventId": "5f0c1d7e9a2b4c6d8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d",
      "eventType": "Invoice.Paid",
      "status": "Delivered",
      "attemptCount": 1,
      "lastResponseCode": 200,
      "events": {
        "createdAt": "2026-09-24T15:10:03Z",
        "lastAttemptAt": "2026-09-24T15:10:03Z",
        "deliveredAt": "2026-09-24T15:10:03Z"
      }
    }
  ],
  "pagination": { "nextPageToken": "" }
}

See Delivery and retries for what each status means.

Manage a subscription

Listing requires the full scope query, the same shape as the body field.

Update a subscription

Send only the fields you're changing:

curl -X PATCH https://api.wingspan.app/v3/platform/webhooks/Wb3nR7tYq1LmK5xPz9cVa2 \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "subscribedEvents": ["Invoice.*", "Payable.*"] }'

A change to the URL, scope, or events applies only to events that happen after the change. Deliveries that haven't started yet are cancelled, not sent to the new URL. Recover anything you need from the event log.

Changing scope requires platform.webhook:write on both the old and the new scope.

Pause and resume

disable sets status to Disabled and cancels pending deliveries. enable sets it back to Active. Re-enabling doesn't replay what you missed while the subscription was disabled. Those events are still in the event log for 30 days.

State changes are separate POST calls, not a status field in PATCH, so every pause and resume is recorded as its own action.

Rotate the signing secret

immediate is required. Use false for a planned rotation:

curl -X POST https://api.wingspan.app/v3/platform/webhooks/Wb3nR7tYq1LmK5xPz9cVa2/rotate-secret \
  -H "Authorization: Bearer $WINGSPAN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "immediate": false }'

The 200 response is the subscription with a new secret, shown once. With "immediate": false, every push for the next 72 hours carries two signatures, one per secret, so you can deploy the new secret without dropping events. Your verifier should accept either (the code on Verify signatures does). With "immediate": true, we revoke every older secret right away and sign only with the new one. Use it when a secret leaks or was lost.

Rotating also cancels deliveries that haven't started. Pick them up from the event log.

What can go wrong

Status and codeWhyWhat to do
422 ValidationErrorThe URL isn't HTTPS, contains a username or password, or resolves to a private or reserved address. Or a pattern is duplicated, unavailable, or can't reach your scope type. errors[] names the field, for example subscribedEvents[2].Fix the field named in errors[].
422Idempotency-Key is missing on create or rotate, the rotate body is invalid (for example, immediate isn't a boolean), or a delivery-list filter or page parameter is invalid.Add the header or fix the field.
400A list request's query string is malformed.Check the scope, page, and filter parameters.
409 ResourceConflictYou retried a create or rotate with the same key and body. Location points to the subscription.Rotate with a new key and "immediate": true if you don't have the secret.
409 IdempotencyKeyConflictYou reused a key with a different body.Use a new key.
409The scope already has 100 subscriptions. An Organization and every Account in it share one limit of 100. A standalone Account tree, and each Person, has its own.Delete unused subscriptions, or combine patterns into fewer subscriptions.
409 WebhookSecretRotationInProgressYou asked for a non-immediate rotation while a 72-hour grace window is still open, or another rotation happened at the same time.Wait for the grace window to end, or send "immediate": true.
403Your token lacks platform.webhook:write on that scope.Use a token with access to the scope.
404 ResourceNotFoundThe subscription doesn't exist or you can't see it.Check the ID and the token.
503 ServiceUnavailableA dependency was briefly unavailable, for example while pausing a subscription.Retry. Send the same Idempotency-Key on a disable retry so it isn't applied twice.

Next steps


Did this page help you?