Developers

Webhooks

POST /api/v1/webhooks subscribes a URL to events and DELETE /api/v1/webhooks/{id} removes it, the REST hook calls a Zapier trigger makes. GET /api/v1/webhooks/samples/{event} answers sample events for its test step, and GET /api/v1/webhooks lists the webhooks. All need Admin.

Overview

POST /api/v1/webhooks subscribes a URL to events, and DELETE /api/v1/webhooks/{id} removes the webhook again. They are the subscribe and unsubscribe calls of a REST hook, which is how a Zapier trigger listens for events. GET /api/v1/webhooks/samples/{event} answers sample events for a trigger's test step, and GET /api/v1/webhooks and GET /api/v1/webhooks/{id} read the webhooks you have.

All five calls need the Admin permission, and the key's member must be a workspace admin when the request runs. A key whose member stops being an admin gets insufficient-scope from then on. Webhooks come with the API, on Business and Enterprise.

A webhook you create here is the same as one an admin sets up in Settings → API. It shows there, it acts as the key's member, and it follows the request, signature and retry rules on Getting started.

Subscribe

curl -X POST "https://attia.app/api/v1/webhooks" \  -H "Authorization: Bearer $ATTIA_API_KEY" \  -H "Idempotency-Key: 0192f3c4-5e21-7b0a-8c3d-2f4e6a8b0c1d" \  -H "Content-Type: application/json" \  -d '{"url": "https://hooks.example.com/attia", "events": ["job.created", "application.created"]}'

The body is a JSON object. url and events are required.

FieldTypeMeaning
urlstringThe https URL Attia sends each event to, at most 2048 characters, with no spaces
eventslist of stringsOne or more events from the list on Getting started, each once
team_idUUID or nullOne team, so the webhook gets only that team's events. candidate.erased is the exception: a candidate belongs to no team, so a webhook that lists it gets it whatever its team. Left out or null, the webhook gets every public team, and a private team's events need a webhook set to that team. A key limited to some teams must name one of them
labelstringThe webhook's name in Settings → API, at most 80 characters. Spaces at either end are removed. Left out, the name is the API key's name

A body that is not a JSON object gets a 400 with the code invalid-request. So does a missing field, a URL that is not https, an event that is not on the list or is named twice, a field outside this table and any query parameter. errors names each one.

The answer is a 201 with the webhook in data. The Location header holds the webhook's path.

HTTP/2 201location: /api/v1/webhooks/0192f3d0-8a14-7e2b-b6c9-1d3f5a7c9e02
{  "data": {    "id": "0192f3d0-8a14-7e2b-b6c9-1d3f5a7c9e02",    "label": "Zapier",    "url": "https://hooks.example.com/attia",    "team_id": null,    "events": ["application.created", "job.created"],    "status": "enabled",    "disabled_reason": null,    "created_at": "2026-10-29T08:15:30.482913Z",    "updated_at": "2026-10-29T08:15:30.482913Z",    "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw8uLtnSjhsL4="  }}
FieldTypeMeaning
idUUIDThe webhook's id. Keep it, because unsubscribing needs it
labelstringThe webhook's name in Settings → API
urlstringThe URL Attia sends each event to
team_idUUID or nullThe one team the webhook follows, or null for every public team
eventslist of stringsThe events the webhook gets, sorted by name
statusstringenabled or disabled. A new webhook is enabled
disabled_reasonstring or nullWhy the webhook is off: owner_left, owner_lost_admin, admin or delivery_failed. Null while it is on
created_atRFC 3339 timestampWhen the webhook was created
updated_atRFC 3339 timestampWhen the webhook last changed
secretstring or nullThe signing secret: whsec_ and 44 characters. Only this answer has it

Store secret when you get it. The API gives it only in this answer, and a replay of the same Idempotency-Key answers null. If you lose it, an admin can copy it on the webhook's page in Settings → API.

In the app, the workspace audit log names the key's member as the one who created the webhook, followed by "via API".

A team the key's member cannot see gets a 400 invalid-request that names team_id. The answer is the same whether the team does not exist, is archived, belongs to another workspace, or is one your key's team access leaves out. A key limited to some teams that sends no team_id gets it too. A workspace that already has 50 webhooks, or a team that has 30, gets a 409 webhook-limit-reached. A view-only workspace gets workspace-view-only.

Unsubscribe

curl -X DELETE "https://attia.app/api/v1/webhooks/0192f3d0-8a14-7e2b-b6c9-1d3f5a7c9e02" \  -H "Authorization: Bearer $ATTIA_API_KEY" \  -H "Idempotency-Key: 0192f3d6-1b7e-7f40-a2c5-8e9d0f1a2b3c"
ParameterTypeMeaning
idUUIDIn the path: the webhook's id, as the create answered it

The answer is a 204 with no body. The webhook and its secret are gone, and the audit log records the delete "via API". Any admin's key can delete any webhook of the workspace, one another admin set up included, and a view-only workspace can still delete.

An id the workspace has no webhook for gets a 404 not-found, and so does the id of another workspace's webhook. Any query parameter gets a 400 invalid-request. Attia ignores a body.

Retry safely

Subscribe and unsubscribe need an Idempotency-Key header. Pick a new value for each call: 1 to 255 letters, digits, - or _. A UUID works. A call without one gets a 400 idempotency-key-required.

When a call times out or the connection drops, send it again with the same key. Within 24 hours, the same key with the same request and the same API key changes nothing and gets the first answer back, plus the header Idempotency-Replayed: true. A replayed subscribe answers the webhook as it was created, with secret set to null. A replayed unsubscribe answers 204 again. The order of the fields in the body does not matter, but a changed value does.

The same key with another request or another API key gets a 422 idempotency-key-reused, and so does the same key after 24 hours, up to 90 days. A request that was refused keeps nothing, so you can send it again with the same key once you have fixed the cause.

List webhooks

GET /api/v1/webhooks lists the webhooks of your workspace, oldest change first. That includes disabled ones and the ones an admin set up in Settings → API. Any admin's key gets every webhook, whoever set it up, and so does a key limited to some teams.

curl "https://attia.app/api/v1/webhooks?limit=50" \  -H "Authorization: Bearer $ATTIA_API_KEY"

All three query parameters are optional.

ParameterTypeDefaultMeaning
limitinteger, 1 to 10025How many webhooks to return
cursorstringnoneThe next_cursor of the previous page
updated_sinceRFC 3339 timestampnoneOnly webhooks updated at or after this time

A parameter outside this table gets a 400 with the code invalid-request. So does a parameter sent twice.

The answer holds the webhooks in data, sorted by updated_at and then id. Each has the fields of the subscribe answer except secret, which no read returns. next_cursor is a string when more webhooks exist and null on the last page. Send it back as cursor until it is null. Pagination explains the sort order.

Get one webhook

GET /api/v1/webhooks/{id} returns the webhook with that id, on or off, in data with the same fields as a webhook in the list. It takes no query parameters, so one gets a 400 with the code invalid-request.

curl "https://attia.app/api/v1/webhooks/0192f3d0-8a14-7e2b-b6c9-1d3f5a7c9e02" \  -H "Authorization: Bearer $ATTIA_API_KEY"

An id the workspace has no webhook for gets a 404 not-found, and so do the id of another workspace's webhook and an id that is not a UUID.

Sample events

GET /api/v1/webhooks/samples/{event} answers up to three events of one kind, newest first, in the shape a delivery sends. Attia builds them from your workspace's records when you call. They are not past deliveries, and Attia sends nothing and stores nothing. A Zapier trigger shows them in its test step.

curl "https://attia.app/api/v1/webhooks/samples/job.deleted" \  -H "Authorization: Bearer $ATTIA_API_KEY"
ParameterTypeMeaning
eventstringIn the path: one event from the list on Getting started

The answer holds the samples in data. The list is empty when the key reaches no record for the event.

{  "data": [    {      "id": "6b3881d8c00f6fd87a89e09f065925f96319d4797975f902b634b972d13eed99",      "type": "job.deleted",      "version": "v1",      "timestamp": "2026-10-01T09:42:10.118305Z",      "workspace_id": "0192f39e-02b1-7a5c-9d3e-4f6a8b0c2d1e",      "revision": 5,      "data": { "id": "0192f3a1-6b02-7d4c-8e11-5a9c0b7d3e01" }    }  ]}
FieldTypeMeaning
idstring64 hex characters: the SHA-256 of sample:, the event, the record's id and its revision or null, joined with colons. The same record at the same revision always gets the same id, and it never equals a delivery's id
typestringThe event in the path
versionstringAlways v1
timestampRFC 3339 timestampWhen the record last changed, or when the candidate was erased
workspace_idUUIDYour workspace's id
revisioninteger or nullThe record's revision. Null on candidate.erased
dataobjectThe job or application as GET /api/v1/jobs/{id} or GET /api/v1/applications/{id} returns it to the key. On job.deleted, application.deleted and candidate.erased it holds only id

Each event reads its own records and takes the three that changed last:

  • job.created and job.updated: the jobs GET /api/v1/jobs?include_archived=true lists for the key.
  • job.deleted: deleted jobs in the teams the key reaches.
  • application.created, application.status_changed and application.updated: the applications GET /api/v1/applications lists for the key.
  • application.deleted: deleted applications in the teams the key reaches.
  • candidate.erased: the candidates erased from the workspace.

An application event also needs Read candidate data and Candidate data in the API turned on, because Attia sends application events only while it is on. candidate.erased needs Read candidate data but not the switch, because it goes out either way. A key without them gets a 403 insufficient-scope. A key limited to some teams gets samples from those teams only, and an empty list for candidate.erased, since a candidate belongs to no team. A webhook set to one team still gets the event itself.

An event that is not on the list gets a 404 not-found. Any query parameter gets a 400 invalid-request.

Zapier

In a Zapier trigger of type REST Hook:

  • performSubscribe sends POST /api/v1/webhooks with url set to bundle.targetUrl, the trigger's events and a new UUID as the Idempotency-Key. Return the answer's data, so Zapier keeps the webhook's id in bundle.subscribeData.
  • performUnsubscribe sends DELETE /api/v1/webhooks/{id} with bundle.subscribeData.id and a new UUID as the Idempotency-Key.
  • perform gets each request body Attia sends, in the shape on Getting started.
  • performList sends GET /api/v1/webhooks/samples/{event} with the trigger's event and returns the answer's data. Zapier shows those samples when someone tests the trigger.

The webhook acts as the member whose key Zapier holds. It turns off when that member leaves the workspace or stops being an admin. Connect Zapier with an admin's key that stays in place.

In a Zapier search, perform sends GET /api/v1/jobs/{id}, GET /api/v1/applications/{id} or GET /api/v1/candidates/{id} and returns the answer's data in an array of one. On a 404 it returns an empty array, which is how a Zapier search says it found nothing.

In a Zapier action, perform sends POST /api/v1/jobs with a new UUID as the Idempotency-Key and returns the answer's data. Each run sends a new key, so replaying a run in Zapier creates a second job.