Skip to main content
Webhooks let you react to Tally events in real time without polling. When a relevant event occurs in your tenant — a new snapshot is published, a counterparty requests a data refresh, or one of your refresh requests is fulfilled — Tally sends an HTTP POST to your registered endpoint with a signed JSON payload. You verify the signature with your subscription secret, process the event, and respond 2xx. Managing subscriptions (create, list, update, rotate secret) is done through the endpoints below.

Supported event types

EventWhen it fires
identity.snapshot.createdA new snapshot is created for a subject in your tenant.
refresh_request.createdA counterparty tenant has created a refresh request for a subject you own.
refresh_request.fulfilledA refresh request your tenant created has been fulfilled by the subject owner.

Create a subscription

POST /v1/webhook-subscriptions Creates a new webhook subscription and returns a one-time plaintext secret you must save immediately. The secret is used to verify the HMAC signature on every subsequent delivery. Requires at least the tenant_reader role in the specified tenant.

Body parameters

tenant_id
string
required
The tenant whose events you want to subscribe to.
target_url
string
required
The HTTPS URL Tally will POST events to. Leading and trailing whitespace is stripped.
event_types
array of strings
required
The event types to subscribe to. Must be a non-empty array containing only supported event types.

Response

201 Created
{
  "webhook_subscription": {
    "subscription_id": "wsub_a1b2c3d4-0000-0000-0000-111122223333",
    "tenant_id": "acme-corp",
    "target_url": "https://hooks.acme.com/tally",
    "status": "active",
    "event_types": [
      "identity.snapshot.created",
      "refresh_request.created"
    ],
    "secret_last_rotated_at": "2024-06-01T09:00:00.000Z",
    "disabled_at": null,
    "created_at": "2024-06-01T09:00:00.000Z"
  },
  "secret": "whsec_4a7b3f9d..."
}
The secret field is returned only once — at subscription creation or after a secret rotation. There is no API to retrieve it again. Store it in a secrets manager (e.g. AWS Secrets Manager, HashiCorp Vault) immediately.

Example

curl -X POST https://api.tally.so/v1/webhook-subscriptions \
  -H "Authorization: Bearer $TALLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "acme-corp",
    "target_url": "https://hooks.acme.com/tally",
    "event_types": ["identity.snapshot.created", "refresh_request.created"]
  }'

List subscriptions

GET /v1/webhook-subscriptions Returns all webhook subscriptions for a tenant. Requires at least the tenant_reader role.

Query parameters

tenant_id
string
required
Filter subscriptions to this tenant.

Response

200 OK{ "items": [WebhookSubscription] }

Get a subscription

GET /v1/webhook-subscriptions/:subscription_id Returns a single webhook subscription by ID. Requires at least the tenant_reader role in the subscription’s tenant.

Path parameters

subscription_id
string
required
The subscription_id (e.g. wsub_a1b2c3d4-...).

Response

200 OK{ "webhook_subscription": WebhookSubscription }

Update a subscription

PATCH /v1/webhook-subscriptions/:subscription_id Updates one or more attributes of an existing subscription. All body fields are optional — only the fields you include are changed. Requires at least the tenant_reader role in the subscription’s tenant.

Path parameters

subscription_id
string
required
The ID of the subscription to update.

Body parameters

target_url
string
A new delivery URL to replace the existing one.
event_types
array of strings
A new set of event types to subscribe to. Replaces the existing list entirely. Must be a non-empty array of supported event types.
status
string
"active" or "disabled". Set to "disabled" to pause delivery without deleting the subscription.

Response

200 OK{ "webhook_subscription": WebhookSubscription } with the updated fields.

Examples

curl -X PATCH \
  "https://api.tally.so/v1/webhook-subscriptions/wsub_a1b2c3d4-0000-0000-0000-111122223333" \
  -H "Authorization: Bearer $TALLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "disabled" }'

Rotate signing secret

POST /v1/webhook-subscriptions/:subscription_id/rotate-secret Generates a new signing secret for the subscription and returns it in plaintext. Deliveries signed with the old secret will begin failing immediately after rotation. Requires at least the tenant_reader role in the subscription’s tenant.

Path parameters

subscription_id
string
required
The ID of the subscription whose secret you want to rotate.

Response

200 OK{ "webhook_subscription": WebhookSubscription, "secret": "..." } with the new plaintext secret and an updated secret_last_rotated_at timestamp.
Update your signature verification code with the new secret before discarding the response. There is a brief window after rotation during which in-flight deliveries signed with the old secret may arrive — plan for this by accepting both secrets simultaneously during your rollover window.

Payload verification

Every delivery includes an X-Tally-Signature HTTP header containing the HMAC-SHA256 hex digest of the raw request body, keyed with your subscription secret. You must verify this signature before processing any event.

How to verify

  1. Read the raw request body as bytes — do not parse it first.
  2. Compute HMAC-SHA256(secret, raw_body) and encode the result as a lowercase hex string.
  3. Compare your computed digest to the value in X-Tally-Signature using a timing-safe comparison function.
  4. Reject the request with 401 if the signatures do not match.
Always use a timing-safe comparison (e.g. crypto.timingSafeEqual in Node.js). A standard string equality check (===) is vulnerable to timing attacks that could allow an attacker to forge signatures.

Node.js example

import crypto from 'crypto';

function verifyWebhookSignature(
  secret: string,
  body: string,
  signature: string
): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body, 'utf8')
    .digest('hex');

  // Both buffers must have the same length for timingSafeEqual.
  // If lengths differ the signature is definitely invalid.
  if (expected.length !== signature.length) return false;

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(signature, 'hex')
  );
}

// Express / Fastify usage
app.post('/hooks/tally', (req, res) => {
  const sig = req.headers['x-tally-signature'] as string;
  const rawBody = req.rawBody; // ensure you capture the raw body string

  if (!verifyWebhookSignature(process.env.TALLY_WEBHOOK_SECRET!, rawBody, sig)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(rawBody);
  // process event...
  res.status(200).send('ok');
});

Example delivery headers

POST /hooks/tally HTTP/1.1
Content-Type: application/json
X-Tally-Signature: 3b4f2c9d8e1a0b7f6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3
Tally delivers events with a short retry backoff if your endpoint returns a non-2xx status. Return 200 OK (or any 2xx) as quickly as possible — enqueue the payload for async processing if your handler is slow, rather than doing heavy work inline during the HTTP response window.