Tally delivers real-time HTTP notifications to URLs you register whenever key events happen on your tenant’s subjects. Rather than polling the API for changes, you register a webhook subscription once and your endpoint receives a signed POST request each time a matching event fires. Each subscription is scoped to a tenant and one or more event types, and every delivery is signed with an HMAC secret so you can verify that the payload genuinely came from Tally.
Supported events
| Event type | When it fires |
|---|
identity.snapshot.created | A new snapshot is created for any subject owned by your tenant (applies whether the snapshot was submitted directly or via the propose/apply flow) |
refresh_request.created | A counterparty tenant creates a refresh request targeting a subject your tenant owns |
refresh_request.fulfilled | A refresh request that your tenant created is fulfilled by the subject owner |
Create a subscription
POST /v1/webhook-subscriptions
Your principal must be an active member of the tenant_id you specify, with at least the tenant_reader role.
curl -X POST https://api.tally.io/v1/webhook-subscriptions \
-H "Authorization: Bearer $TALLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "acme-kyc",
"target_url": "https://hooks.yourapp.com/tally",
"event_types": [
"identity.snapshot.created",
"refresh_request.created",
"refresh_request.fulfilled"
]
}'
Request body fields
| Field | Required | Description |
|---|
tenant_id | Yes | Your tenant ID — events for this tenant will be delivered to the subscription |
target_url | Yes | The HTTPS URL Tally should POST event payloads to |
event_types | Yes | Non-empty array of event type strings from the supported list above |
The 201 Created response includes both the subscription record and a secret. Save the secret immediately — it is only returned at creation time and cannot be retrieved again.
{
"webhook_subscription": {
"subscription_id": "wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "acme-kyc",
"target_url": "https://hooks.yourapp.com/tally",
"status": "active",
"event_types": [
"identity.snapshot.created",
"refresh_request.created",
"refresh_request.fulfilled"
],
"secret_last_rotated_at": "2026-03-01T12:00:00Z",
"disabled_at": null,
"created_at": "2026-03-01T12:00:00Z"
},
"secret": "whsec_4a8f3c2b1e9d7a6f5c4b3e2d1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2"
}
Verifying webhook payloads
Tally signs every webhook delivery by computing HMAC-SHA256(secret, raw_request_body) and sending the result as the X-Tally-Signature header. You must verify this signature before processing any payload to ensure it was not tampered with in transit.
import crypto from 'crypto';
function verifySignature(secret: string, body: string, signature: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Use this in your webhook handler before any other processing:
import express from 'express';
const app = express();
app.post('/tally', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-tally-signature'] as string;
const rawBody = req.body.toString('utf8');
if (!verifySignature(process.env.TALLY_WEBHOOK_SECRET!, rawBody, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
console.log('Received event:', event.type);
// Handle the event...
res.status(200).send('ok');
});
Use crypto.timingSafeEqual for the comparison — never use === or other string equality checks. Non-constant-time comparisons are vulnerable to timing attacks that could allow an attacker to forge valid signatures.
Manage subscriptions
List subscriptions
Get one subscription
Update a subscription
Retrieve all subscriptions for a tenant:curl "https://api.tally.io/v1/webhook-subscriptions?tenant_id=acme-kyc" \
-H "Authorization: Bearer $TALLY_API_KEY"
{
"items": [
{
"subscription_id": "wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "acme-kyc",
"target_url": "https://hooks.yourapp.com/tally",
"status": "active",
"event_types": ["identity.snapshot.created"],
"secret_last_rotated_at": "2026-03-01T12:00:00Z",
"disabled_at": null,
"created_at": "2026-03-01T12:00:00Z"
}
]
}
Retrieve a specific subscription by its ID:curl https://api.tally.io/v1/webhook-subscriptions/wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $TALLY_API_KEY"
Use PATCH to change the target_url, event_types, or status of a subscription. You only need to include the fields you want to change — omitted fields keep their current values.# Update the target URL and event types
curl -X PATCH https://api.tally.io/v1/webhook-subscriptions/wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $TALLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://hooks.yourapp.com/tally-v2",
"event_types": ["identity.snapshot.created"]
}'
# Disable a subscription
curl -X PATCH https://api.tally.io/v1/webhook-subscriptions/wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $TALLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "disabled" }'
Setting status to "disabled" stops all deliveries immediately. Set it back to "active" to resume delivery. Disabling a subscription does not delete it — the record and its history are preserved.
Rotate your signing secret
If your secret is compromised, or as part of a regular credential rotation schedule, call POST /v1/webhook-subscriptions/:subscription_id/rotate-secret to issue a new secret immediately.
curl -X POST https://api.tally.io/v1/webhook-subscriptions/wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890/rotate-secret \
-H "Authorization: Bearer $TALLY_API_KEY"
The response includes the new secret alongside the updated subscription record:
{
"webhook_subscription": {
"subscription_id": "wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "acme-kyc",
"target_url": "https://hooks.yourapp.com/tally",
"status": "active",
"event_types": ["identity.snapshot.created"],
"secret_last_rotated_at": "2026-06-15T08:00:00Z",
"disabled_at": null,
"created_at": "2026-03-01T12:00:00Z"
},
"secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e"
}
Update your TALLY_WEBHOOK_SECRET environment variable (or secret store) with the new value immediately. Tally switches to signing with the new secret right away — deliveries that arrive after the rotation will use the new secret, so update your verification logic before the rotation response arrives or accept a brief window where signature checks may fail.
The plaintext secret is only returned at creation time and at rotation time. Tally stores a hashed and encrypted version of the secret internally and cannot retrieve or display the original value after those moments. If you lose your secret, rotate it immediately to obtain a new one.