# Webhooks

> Send signed events to your own systems when people, Knowledge, playbooks or the workspace change, and check each request came from Shuffl.

Audience: Workspace administrators and developers

Kind: How-to guide

Canonical: https://shuffl.ai/docs/webhooks

<a id="add"></a>
## Add an endpoint

1. Open Settings → API keys and webhooks → Webhooks and choose Add endpoint.

2. Enter the URL that should receive events and, optionally, a description.

3. Pick whole groups or open a group to pick single events.

4. Save the signing secret Shuffl shows next. You can reveal it again later from the endpoint.

5. Shuffl sends a webhook.test event straight away. Check it under Deliveries, or press Send test event at any time.

Workspace owners and administrators manage webhooks in Settings → API keys and webhooks → Webhooks. A workspace can have up to 10 endpoints, each with its own URL, events and signing secret.

Shuffl sends one HTTPS POST per event to every endpoint subscribed to it, a few seconds after the change is saved. Endpoints must use https and a public address; Shuffl doesn’t follow redirects.

- [Open Webhooks](https://shuffl.ai/app/settings/webhooks)

<a id="payload"></a>
## What each request carries

Every request body is one JSON event. id is unique per event and stays the same on every retry and resend, so use it to ignore duplicates.

Events can arrive out of order. Compare created_at, or read the current state through the API.

Payloads are thin on purpose. They carry ids, states, counts and revisions, never names, emails, Slack ids, message text or document titles.

To learn more, read the record through the API with a key that holds the event group’s read scope.

actor.type is user when a person made the change, with their user id. It is system, with id null, when Shuffl, Slack, an HR system or a schedule did.

```json
{
  "id": "evt_6f1c2a8e4b3d4f0a9c7e5d2b1a0f3e4c",
  "type": "person.created",
  "api_version": "2026-10-17",
  "created_at": "2026-10-17T09:12:44.512Z",
  "workspace_id": "<workspace id>",
  "actor": { "type": "user", "id": "<user id>" },
  "data": { "person_id": "<person id>" }
}
```

<a id="verify"></a>
## Check the signature

Shuffl signs every request the Standard Webhooks way, so most webhook libraries verify it as is. @shuffl/sdk exports verifyWebhook, which does all of this and returns the typed event.

The signature is HMAC-SHA256 over id.timestamp.body, keyed with the base64 part of your whsec_ secret, then base64-encoded and prefixed v1,.

While a rolled secret’s old value is still kept, the header carries both signatures separated by a space. Accept the request if either matches.

### Headers on each request

- webhook-id: the event id.
- webhook-timestamp: Unix seconds.
- webhook-signature: one or more v1, signatures.

### If you verify by hand

- Verify the raw body before parsing it.
- Compare in constant time.
- Refuse timestamps more than five minutes from your clock.

SDK:

```ts
import { verifyWebhook, WebhookVerificationError } from '@shuffl/sdk';

// Read the raw body: verify before parsing or reformatting it.
export async function POST(request: Request) {
  const body = await request.text();
  try {
    const event = await verifyWebhook({
      body,
      headers: request.headers,
      secret: process.env.SHUFFL_WEBHOOK_SECRET!,
    });
    if (event.type === 'person.created') {
      // event.data.person_id is typed
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError)
      return new Response('Bad signature', { status: 401 });
    throw error;
  }
}
```

Node:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(body, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    throw new Error('Too old');
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key)
    .update(`${id}.${timestamp}.${body}`)
    .digest();
  const valid = headers['webhook-signature']
    .split(' ')
    .map((part) => Buffer.from(part.replace(/^v1,/, ''), 'base64'))
    .some((given) => given.length === expected.length && timingSafeEqual(given, expected));
  if (!valid) throw new Error('Bad signature');
  return JSON.parse(body);
}
```

Python:

```python
import base64, hashlib, hmac, json, time

def verify(body: bytes, headers, secret: str):
    msg_id = headers['webhook-id']
    timestamp = headers['webhook-timestamp']
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError('Too old')
    key = base64.b64decode(secret.removeprefix('whsec_'))
    signed = f'{msg_id}.{timestamp}.'.encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    given = [p.split(',', 1)[1].encode() for p in headers['webhook-signature'].split(' ')]
    if not any(hmac.compare_digest(expected, g) for g in given):
        raise ValueError('Bad signature')
    return json.loads(body)
```

- [Standard Webhooks specification](https://www.standardwebhooks.com/)

<a id="retries"></a>
## Answer quickly; Shuffl retries the rest

Answer with any 2xx within 10 seconds to accept an event. Do the work afterwards, not before answering.

Shuffl retries timeouts, connection errors, 408, 429 and 5xx answers. The tries come after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: a little over a day in all.

A Retry-After header pushes the next try later. Other 4xx answers and redirects are not retried.

### When an endpoint is turned off

- An endpoint that answers 410 Gone is turned off at once.
- One whose deliveries have failed for five days is turned off too, and workspace owners and administrators get an email.
- If the administrator who added an endpoint loses that role, it pauses until another administrator turns it back on.

<a id="manage"></a>
## Read deliveries, resend and roll the secret

Open an endpoint to see its deliveries: each one’s request body, headers with the signature hidden, and every attempt’s status, time and the start of the response.

Shuffl keeps 30 days of deliveries. Endpoint changes, secret reveals and resends appear in Settings → Activity.

### Actions on an endpoint

- Resend sends one delivery again with the same event id.
- Resend failed deliveries sends every failed event from the last hour, day or week once more.
- Roll secret makes a new secret and can keep the old one for an hour, a day or a week while you update your receiver.
- Turning an endpoint off stops deliveries. Events that happen while it is off are not sent later.
- Deleting an endpoint removes its delivery log.

- [Open Webhooks](https://shuffl.ai/app/settings/webhooks)

<a id="api"></a>
## Manage webhooks from code

Everything in Settings → API keys and webhooks → Webhooks is also in the API, the SDK, the CLI and the workspace MCP server.

Changes are management commands like any other. Each takes confirm set to the operation name and a requestKey, and returns {receipt, result}.

### Operations

- webhooks.list, webhooks.catalog, webhooks.get, webhooks.create, webhooks.update, webhooks.delete, webhooks.rotateSecret and webhooks.sendTest.
- webhooks.deliveries.list, deliveries.get, deliveries.replay and deliveries.replayFailed.

### Permissions

- A key needs webhooks:read or webhooks:write.
- It can subscribe an endpoint only to the event groups whose read scope it also holds.
- A key gets the secret when it creates an endpoint or rolls the secret. Revealing it again is only in Settings.

SDK:

```ts
const { result } = await shuffl.webhooks.create({
  confirm: 'webhooks.create',
  requestKey: 'hr-sync-endpoint',
  url: 'https://example.com/webhooks/shuffl',
  events: ['person.created', 'person.access_changed'],
});
// Save result.secret now; a retry with the same requestKey returns result: null.
const { deliveries } = await shuffl.webhooks.deliveries.list({
  endpointId: result!.endpoint.id,
  state: 'failed',
});
```

CLI:

```sh
shuffl webhooks list --json
shuffl webhooks deliveries list --endpoint-id "<endpoint id>" --state failed --json
```

HTTP:

```sh
curl -X POST https://shuffl.ai/api/v1/manage/webhooks/list \
  -H "Authorization: Bearer $SHUFFL_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

- [API endpoint permissions](https://shuffl.ai/docs/api-permissions)

- [Use the SDK and HTTP API](https://shuffl.ai/docs/sdk)

<a id="events"></a>
## Events

Each event lists the data fields it can carry. An endpoint receives only the events it picked.

### Actions

Requests Mello filed in another platform for a person, and what the platform decided. Needs users:read for an API key.

- `action.approved`: The platform approved a request Mello filed (`action_id`, `person_id`, `kind`, `provider`)
- `action.cancelled`: A request Mello filed was cancelled (`action_id`, `person_id`, `kind`, `provider`)
- `action.declined`: The platform declined a request Mello filed (`action_id`, `person_id`, `kind`, `provider`)
- `action.failed`: A request could not be filed in the platform (`action_id`, `person_id`, `kind`, `provider`)
- `action.submitted`: Mello filed a request in the platform, pending there (`action_id`, `person_id`, `kind`, `provider`)

### Connections

Connections programs, rounds and who takes part. Needs programs:read for an API key.

- `participant.opted_in`: A person joined a Connections program (`person_id`, `program_id`, `revision`)
- `participant.opted_out`: A person left a Connections program (`person_id`, `program_id`, `revision`)
- `participant.profile_updated`: A person updated their profile (`person_id`)
- `program.archived`: A program was archived (`program_id`)
- `program.created`: A Connections program or playbook was created (`program_id`, `kind`)
- `program.paused`: A program was paused (`program_id`)
- `program.resumed`: A program was switched on (`program_id`)
- `program.updated`: A program’s settings changed (`program_id`, `kind`)
- `round.planned`: Shuffl planned a round of introductions (`round_id`, `groups`, `unmatched`)
- `round.requested`: Someone asked for a round of introductions (`round_id`, `retry`)

### HR team

Who answers HR requests. Needs hr:read for an API key.

- `hr.responder.granted`: Someone joined the HR team that answers requests (`person_id`, `revision`)
- `hr.responder.revoked`: Someone left the HR team (`person_id`, `revision`)
- `hr.team.renamed`: The HR team’s name changed (`revision`)

### Knowledge

Proposals, drafts, published pages and connected sources. Needs knowledge:read for an API key.

- `knowledge.document.archived`: A page was archived (`document_id`)
- `knowledge.document.audience_changed`: Who can read a published page changed (`document_id`, `version_id`, `audience`, `previous_audience`)
- `knowledge.document.published`: A page was published (`document_id`, `version_id`, `audience`)
- `knowledge.document.restored`: An archived page was restored (`document_id`)
- `knowledge.document.withdrawn`: A published page was withdrawn (`document_id`)
- `knowledge.draft.saved`: A draft of a page was saved (`document_id`, `version_id`)
- `knowledge.proposal.created`: Mello proposed new or changed Knowledge for review (`proposal_id`, `revision`)
- `knowledge.proposal.published`: A verifier approved a proposal, as written or edited (`proposal_id`, `revision`, `document_id`, `edited`)
- `knowledge.proposal.rejected`: A verifier turned a proposal down (`proposal_id`, `revision`)
- `knowledge.section.removed`: A section was removed from a handbook page (`document_id`)
- `knowledge.source.access_lost`: Shuffl can no longer read a connected document (`document_id`)
- `knowledge.source.connected`: A Knowledge source such as Google Drive was connected (`connection_id`)
- `knowledge.source.disconnected`: A Knowledge source was disconnected (`connection_id`)
- `knowledge.verifier.granted`: Someone can now verify Knowledge (`person_id`, `revision`)
- `knowledge.verifier.revoked`: Someone can no longer verify Knowledge (`person_id`, `revision`)

### People

People, roles, access, identities and departments. Needs users:read for an API key.

- `person.access_changed`: A person’s app access or employment changed (`person_id`, `status`, `previous_status`, `employment`, `previous_employment`, `revision`)
- `person.access_suspended`: A person’s access was suspended (`person_id`)
- `person.created`: A person was added to the directory (`person_id`, `revision`)
- `person.identity_linked`: A Slack or single sign-on identity was linked to a person (`person_id`, `identity_id`, `provider`)
- `person.identity_revoked`: A linked identity was removed from a person (`person_id`, `identity_id`, `provider`)
- `person.manager_changed`: A person’s manager changed (`person_id`)
- `person.role_changed`: A person’s workspace role changed (`person_id`, `role`, `previous_role`)
- `person.updated`: A person’s directory record changed (`person_id`, `revision`)

### Playbooks

Playbook enrollments, steps, buddies and check-ins. Needs playbooks:read for an API key.

- `playbooks.approved`: HR approved a person’s playbook steps (`enrollment_id`)
- `playbooks.buddy.accepted`: A buddy said yes (`enrollment_id`)
- `playbooks.buddy.assigned`: A buddy was asked to help a new hire (`enrollment_id`, `buddy_person_id`)
- `playbooks.buddy.declined`: A buddy said no (`enrollment_id`)
- `playbooks.check_in.answered`: A new hire answered a check-in: all_good, or need_help when they asked HR for help (`step_id`, `enrollment_id`, `answer`)
- `playbooks.enrolled`: A person started a playbook (`enrollment_id`, `person_id`, `program_id`, `with_buddy`)
- `playbooks.enrollment_removed`: A person was taken off a playbook (`enrollment_id`, `reason`)
- `playbooks.schedule_changed`: A person’s playbook schedule changed (`enrollment_id`)
- `playbooks.step.failed`: A playbook step could not be delivered (`step_id`, `reason`)
- `playbooks.step.needs_review`: A playbook step waits for HR before it sends (`step_id`)
- `playbooks.step.sent`: A playbook step reached its person (`step_id`)

### Webhooks

Test events and endpoints Shuffl turned off. Needs webhooks:read for an API key.

- `webhook.endpoint.disabled`: Shuffl turned an endpoint off after it kept failing (`endpoint_id`, `reason`)
- `webhook.test`: A test event someone sent from Settings or the API (`endpoint_id`)

### Workspace

The workspace itself and its Slack connection. Needs team:read for an API key.

- `slack.connected`: Slack was connected
- `slack.connection_expired`: Slack stopped accepting Shuffl’s connection (`reason`)
- `slack.uninstalled`: Shuffl was removed from Slack (`reason`)
- `workspace.deletion_requested`: An owner asked to delete the workspace (`delete_after`)
- `workspace.exported`: Someone exported the workspace’s data
- `workspace.renamed`: The workspace was renamed
- `workspace.restored`: The workspace was restored (`deletion_canceled`)
- `workspace.slack_join_access_changed`: Who can join from Slack changed

## Related guides

- [Use the SDK and HTTP API](https://shuffl.ai/docs/sdk)

- [REST API reference](https://shuffl.ai/docs/api-reference)

- [API endpoint permissions](https://shuffl.ai/docs/api-permissions)

- [Use the Shuffl CLI](https://shuffl.ai/docs/cli)

Agent documentation index: https://shuffl.ai/llms.txt

