Skip to content

Ticket Integrations ​

Wire a project's ticket board to another system, in both directions:

  • Outbound — webhooks. Wick POSTs a JSON event to your endpoint whenever a ticket is created, moved, assigned, or deleted.
  • Inbound — REST API. Your system creates and updates tickets with a Personal Access Token.
  • Custom buttons. A button that POSTs to your URL on click — on a ticket's page (it sends that ticket) or in the ticket list's toolbar (it sends the list's filter and the tickets it matches) — see Custom buttons.

All three are configured per project, under Project settings → Ticket system → Integrations. All are off (empty) until you add one.

Setup ​

  1. Open Project settings → Ticket system, make sure ticket mode is on.
  2. Expand Integrations.
  3. For the API: switch REST API on. Copy the base URL shown there.
  4. For webhooks: Add webhook, fill in the URL, set a signing secret, pick the events, then Send test to prove the endpoint before a real ticket depends on it.
  5. For a custom button: Add button, fill in a label and a URL, choose where it shows (a ticket's page, or the ticket list), and save — see Custom buttons.

You need a Personal Access Token for the REST API. Create one at /profile/tokens — see Access Tokens. A token acts as you: it reaches exactly the projects your user can see, and no others.

The API toggle is per project

A token cannot touch a project whose REST API is switched off — those requests answer 403 with the REST API is disabled for this project. A project you cannot see at all answers 403 you don't have access to this project; one that does not exist answers 404.


REST API ​

Base URL and auth ​

https://<your-wick-host>/api

Every endpoint path in this reference is relative to this base URL — append it as-is. GET /projects/{id}/tickets means:

https://<your-wick-host>/api/projects/{id}/tickets

Every request carries the token:

bash
-H "Authorization: Bearer $WICK_TOKEN"

Set up the shell for every example below:

bash
export WICK_HOST="https://wick.abc.com"
export WICK_API="$WICK_HOST/api"
export WICK_TOKEN="wick_pat_..."
export PROJECT="proj_7f21c9"

Errors ​

Failures are JSON with a single error key.

json
{ "error": "invalid status in_review (want open, in_progress, waiting, done)" }
StatusMeaning
400Malformed JSON, or a value the board rejects (unknown status, empty title).
401Missing, malformed, revoked, or unapproved token.
403you don't have access to this project — the project exists but your user is not its owner and has no tag grant. Or the REST API is disabled for this project — switch it on under Project settings → Ticket system → Integrations.
404Ticket or project does not exist.
500Server-side failure writing the ticket.

List tickets ​

GET /projects/{projectID}/tickets
QueryDefaultMeaning
rows3Session rows per card. 0 returns counts only.
statusesallComma-separated columns to include. ?statuses= returns none.
assigneeeveryoneA user id, me, or none (unassigned) — or a comma list of them (me,usr_a91f,none): a ticket matches when ANY of its assignees is listed.
untracked01 also returns sessions with no ticket.
untracked_limit—Caps that list.
untracked_ownereveryoneme limits the untracked list (and its count) to sessions the caller owns.
untracked_after—Next page of the untracked list: the untracked_next cursor from the previous response. Opaque; a malformed value is a 400.

The untracked list comes in the sidebar's order (running first, then most recently used, then id). When more rows follow, the response carries untracked_next, a cursor to pass as untracked_after for the next page; it is absent on the last page. untracked_total counts every match regardless of paging.

bash
curl -s "$WICK_API/projects/$PROJECT/tickets?rows=0" \
  -H "Authorization: Bearer $WICK_TOKEN"

Only the tickets that are open and assigned to you:

bash
curl -s "$WICK_API/projects/$PROJECT/tickets?statuses=open,in_progress&assignee=me" \
  -H "Authorization: Bearer $WICK_TOKEN"

Create a ticket ​

POST /projects/{projectID}/tickets
FieldRequiredNotes
titleyesTrimmed; must not be empty.
bodynoMarkdown description. Rendered as markdown on the ticket page.
idnoAdopt an external id instead of a generated one — see Adopting an external id.
statusnoDefaults to the board's first column. Must be one of the project's keys.
assigneenoA wick user id. Omit it and the token's own user is assigned; send "" for deliberately unassigned.
fieldsnoCustom fields, keyed by the project's field keys.
session_idnoAttaches an existing chat — "turn this conversation into a ticket".
bash
curl -s -X POST "$WICK_API/projects/$PROJECT/tickets" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Checkout returns 502 on retry",
    "body": "Reproduced on staging with retry enabled. Fails after the third attempt.",
    "status": "open",
    "assignee": "",
    "fields": { "type": "bug", "priority": "high" }
  }'
json
{
  "id": "T-4F2A",
  "project_id": "proj_7f21c9",
  "title": "Checkout returns 502 on retry",
  "body": "Reproduced on staging with retry enabled. Fails after the third attempt.",
  "status": "open",
  "fields": { "type": "bug", "priority": "high" },
  "created_at": "2026-08-25T04:11:09Z",
  "updated_at": "2026-08-25T04:11:09Z"
}

Adopting an external id ​

By default wick mints a short, human-quotable id (T-4F2A) — the code that goes on the board card and gets typed into chat. When the ticket mirrors a record that already has an identity elsewhere, send that identity as id and the ticket becomes addressable by it directly:

bash
curl -s -X POST "$WICK_API/projects/$PROJECT/tickets" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "1f2e3d4c-5b6a-7988-9a0b-1c2d3e4f5a6b",
    "title": "Checkout returns 502 on retry"
  }'

An id must fit [A-Za-z0-9._-], be at most 64 characters, and is kept verbatim — TIK-2026-001 stays exactly that. Two shapes are refused: anything that could escape or hide inside the project's ticket directory (.., a path separator, a leading dot), and the generated T-XXXX form, which stays reserved so the generator never has to check whether someone claimed a code by hand.

A uuid is the one id that gets normalised. Notion hands the same page id out in two shapes — dashless in a page URL, dashed from its API — and they must not become two tickets, so both fold to the dashless lowercase form:

json
{ "id": "1f2e3d4c5b6a79889a0b1c2d3e4f5a6b", "…": "…" }

That is what makes the mapping disposable. The source system already knows the page id, so it can read the ticket back with GET /tickets/1f2e3d4c5b6a79889a0b1c2d3e4f5a6b without storing anything, and a second create from the same page is refused with 400 rather than quietly opening a duplicate.

Lookups stay cheap: a ticket id is its directory name, so addressing one by its adopted id is a direct file read — nothing lists the board or opens every ticket to find it.

Two things to weigh before using it. The id is long where a generated one is short, so it reads worse on a card and in chat. And a board mixing both kinds carries two id shapes at once — fine when the external system is the system of record, worse when tickets arrive from everywhere. Omit id and nothing changes: tickets created by hand, by an agent, or by an auto-create rule keep getting T-XXXX.

Get one ticket ​

GET /tickets/{ticketID}
bash
curl -s "$WICK_API/tickets/T-4F2A" -H "Authorization: Bearer $WICK_TOKEN"

Board query parameters ​

GET /projects/{id}/tickets sends what the caller says it will draw:

ParameterEffect
?fields=allEvery stored field per card, not just the ones marked show on card. A machine reading this board needs the keys the board does not draw — an external id, a mirror's page reference — and without them a sync cannot recognise the tickets it created and re-creates them on every run
?rows=NSession rows per card (0 = counts only)
?statuses=a,bOnly these columns
?assignee=ID,me,noneOnly these people's tickets (any of them); none = unassigned. A single value works as before
?untracked=1Include the untracked chat list

Update a ticket ​

PATCH /tickets/{ticketID}

Every field is optional — send only what changes. An edit bumps updated_at (what the follow-up and auto-resolve timers read) unless the request says otherwise — see updated_at.

Move it to another column:

bash
curl -s -X PATCH "$WICK_API/tickets/T-4F2A" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "in_progress"}'

Assign it:

bash
curl -s -X PATCH "$WICK_API/tickets/T-4F2A" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"assignee": "usr_a91f"}'

Retitle, reassign, and set fields at once:

bash
curl -s -X PATCH "$WICK_API/tickets/T-4F2A" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Checkout 502s on payment retry",
    "assignee": "usr_a91f",
    "fields": { "priority": "urgent", "type": "incident" }
  }'

Clearing a field

fields merges rather than replaces. Send a field as "" to delete it; other fields are left alone. To unassign, send "assignee": "". To clear the description, send "body": "" explicitly — omitting body leaves it unchanged.

updated_at — for a mirror, not for an editor ​

A sync writing tickets on somebody else's behalf can say WHEN the change really happened:

ValueEffect
absentupdated_at becomes now. This is every edit made by a person
RFC3339 timestampupdated_at becomes that instant — the source system's own edit time
"keep"updated_at is left exactly as it was
bash
# the page changed in the other system at 08:30, and that is what the board
# should say — not "just now, because my importer happened to run"
curl -s -X PATCH "$WICK_API/tickets/T-4F2A" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Renamed upstream", "updated_at": "2026-09-12T08:30:00Z"}'

# pure bookkeeping — record that the mirror checked this ticket, without
# claiming the work moved
curl -s -X PATCH "$WICK_API/tickets/T-4F2A" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fields": {"synced_at": "2026-09-15T11:00:00Z"}, "updated_at": "keep"}'

Why it matters: the board sorts on updated_at and every card prints it. A sync that stamps now on a hundred tickets makes the whole board read "just now" and orders it by when the importer ran rather than by when the work moved. A value that is neither RFC3339 nor "keep" is refused (400) rather than quietly treated as now.

The idle timers do not read it

updated_at is a display value, so a mirror can set it to a date months old. The follow-up and auto-resolve timers run from touched_at — when wick last wrote the ticket — which is stamped on every write regardless. Without that split, importing a ticket last edited in June closes it on arrival.

Close it — use the key your board marks as finished:

bash
curl -s -X PATCH "$WICK_API/tickets/T-4F2A" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "done"}'

Delete a ticket ​

DELETE /tickets/{ticketID}?sessions=keep|delete
sessionsEffect
keep (default)Ticket goes; its chats survive as untracked.
deleteThe chats are deleted with it, notes and history included.
bash
# Default: the conversations survive.
curl -s -X DELETE "$WICK_API/tickets/T-4F2A" \
  -H "Authorization: Bearer $WICK_TOKEN"

sessions=delete is not recoverable

A ticket is cheap to recreate; the conversations under it are not. The destructive shape has to be asked for by name.

Attach / detach a session ​

PUT    /tickets/{ticketID}/sessions/{sessionID}
DELETE /tickets/{ticketID}/sessions/{sessionID}

A session belongs to exactly one ticket, so attaching one that sits on another ticket moves it, carrying its notes across.

bash
curl -s -X PUT "$WICK_API/tickets/T-4F2A/sessions/sess_9931" \
  -H "Authorization: Bearer $WICK_TOKEN"

Notes ​

GET    /notes?ticket_id=T-4F2A
POST   /notes
PATCH  /notes/{noteID}
DELETE /notes/{noteID}

Scope reads with ?ticket_id= or ?session_id=; a session that belongs to a ticket resolves to the ticket's notes.

bash
curl -s -X POST "$WICK_API/notes" \
  -H "Authorization: Bearer $WICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticket_id": "T-4F2A", "body": "Reproduced on staging with retry enabled."}'

Read the board's schema ​

GET /projects/{projectID}/tickets?rows=0

The board response carries statuses and config.fields — the valid status keys and field keys for everything above. Read these rather than hardcoding open / done: a project renames its own stages. rows=0 keeps the response to counts and schema, without paying for session rows.

bash
curl -s "$WICK_API/projects/$PROJECT/tickets?rows=0" \
  -H "Authorization: Bearer $WICK_TOKEN" | jq '{statuses, fields: .config.fields}'

There is no token-authed GET /projects/{id} — the bare project route is part of the browser UI surface and stays cookie-only on purpose.

The event catalogue ​

GET /ticket-events
bash
curl -s "$WICK_API/ticket-events" -H "Authorization: Bearer $WICK_TOKEN"

Webhooks ​

Request shape ​

Each delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-Agentwick-tickets/1
X-Wick-EventThe event name, e.g. ticket.status_changed.
X-Wick-DeliveryUnique delivery id — dedupe on this.
X-Wick-Signaturesha256=<hex> HMAC of the raw body. Only when a secret is set.

Any custom headers you configure are also sent. X-Wick-Signature cannot be overridden by one — a misconfiguration must not silently disable verification.

Retries and ordering ​

  • 3 attempts, backing off ~1s, 5s, 25s. 10s timeout per attempt.
  • 2xx is success. A 4xx other than 408 / 429 stops the retries — the receiver has said the request itself is wrong, so repeating it changes nothing.
  • Deliveries run in parallel. Two rapid changes to one ticket can arrive out of order: compare delivered_at, or re-read the ticket over the REST API when order matters.
  • There is no durable queue. If your receiver must never miss an event, reconcile with GET /projects/{id}/tickets on startup.

Envelope ​

The full ticket rides on every event, not just the diff — a receiver that missed an earlier delivery can still act on current state without calling back.

json
{
  "id": "evt_9K3PQR7A",
  "event": "ticket.status_changed",
  "delivered_at": "2026-08-25T04:11:09.412Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "user", "id": "usr_a91f", "name": "Dana Reyes" },
  "ticket": {
    "id": "T-4F2A",
    "project_id": "proj_7f21c9",
    "title": "Checkout returns 502 on retry",
    "body": "Reproduced on staging with retry enabled.",
    "status": "in_progress",
    "assignee": "usr_a91f",
    "fields": { "type": "bug", "priority": "high" },
    "sessions": ["sess_9931"],
    "created_at": "2026-08-25T03:02:11Z",
    "updated_at": "2026-08-25T04:11:09Z"
  },
  "changes": {
    "status": { "from": "open", "to": "in_progress" }
  }
}

actor.type ​

ValueWho
userA human in the wick web UI.
apiA Personal Access Token over the REST API — including your own writes.
agentAn AI agent acting through MCP.
systemWick itself: the follow-up sweeper, auto-resolve, auto-create.

Avoiding echo loops

If your system writes back to wick, ignore events whose actor.type is api (or match actor.id against your token's user) — otherwise your own writes come straight back at you.

changes ​

Present on ticket.updated, ticket.status_changed, and ticket.assigned. Keys are status, assignee, title, body, and fields.<key>:

json
{
  "changes": {
    "assignee": { "from": "", "to": "usr_a91f" },
    "fields.priority": { "from": "high", "to": "urgent" }
  }
}

A field that was cleared has "to": ""; one newly set has "from": "".

Events ​

EventFires whenExtra fields
ticket.createdA ticket is created — by hand, by an agent, by auto-create, or over the API.—
ticket.updatedAny field changed. Also fires alongside the two below.changes
ticket.status_changedThe status moved to another column.changes.status
ticket.assignedThe assignee changed (including being cleared).changes.assignee
ticket.deletedA ticket was deleted. ticket is the last copy you will get.—
ticket.session_attachedA chat was linked to the ticket.session
ticket.session_detachedA chat was unlinked.session
ticket.note_addedA note was added.note
ticket.followupThe sweeper nudged a stale ticket's agent.—
ticket.auto_resolvedThe sweeper closed an untouched ticket.changes.status

updated fires with the specific events

A status move sends both ticket.status_changed and ticket.updated. Subscribe to the specific event if you only care about board movement; subscribe to ticket.updated to mirror every edit without enumerating each event as new ones are added. Subscribing to both means two deliveries for one change.

ticket.action and ticket.board_action are not in this list

Clicking a custom button fires ticket.action (a ticket's page) or ticket.board_action (the ticket list), but either one is delivered only to that button's own URL — neither is part of the catalogue above and neither can be subscribed to from a regular webhook row. See Custom buttons.

ticket.created ​

json
{
  "id": "evt_2B8XKD1M",
  "event": "ticket.created",
  "delivered_at": "2026-08-25T03:02:11.905Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "api", "id": "usr_a91f", "name": "Dana Reyes" },
  "ticket": {
    "id": "T-4F2A",
    "project_id": "proj_7f21c9",
    "title": "Checkout returns 502 on retry",
    "status": "open",
    "assignee": "",
    "fields": { "type": "bug", "priority": "high" },
    "created_at": "2026-08-25T03:02:11Z",
    "updated_at": "2026-08-25T03:02:11Z"
  }
}

ticket.assigned ​

json
{
  "id": "evt_5QT0WJ4C",
  "event": "ticket.assigned",
  "delivered_at": "2026-08-25T03:40:52.117Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "user", "id": "usr_11c4", "name": "Sam Okafor" },
  "ticket": { "id": "T-4F2A", "status": "open", "assignee": "usr_a91f", "…": "…" },
  "changes": { "assignee": { "from": "", "to": "usr_a91f" } }
}

ticket.session_attached ​

json
{
  "id": "evt_7MC2VB9E",
  "event": "ticket.session_attached",
  "delivered_at": "2026-08-25T03:12:44.301Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "system" },
  "ticket": { "id": "T-4F2A", "sessions": ["sess_9931"], "…": "…" },
  "session": "sess_9931"
}

ticket.note_added ​

json
{
  "id": "evt_8ND4YF2G",
  "event": "ticket.note_added",
  "delivered_at": "2026-08-25T04:20:03.884Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "agent", "id": "main", "name": "main" },
  "ticket": { "id": "T-4F2A", "…": "…" },
  "note": "Reproduced on staging with retry enabled."
}

ticket.auto_resolved ​

json
{
  "id": "evt_3RH6ZP5K",
  "event": "ticket.auto_resolved",
  "delivered_at": "2026-08-28T04:00:00.000Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "system" },
  "ticket": { "id": "T-4F2A", "status": "done", "…": "…" },
  "changes": { "status": { "from": "waiting", "to": "done" } }
}

ticket.deleted ​

json
{
  "id": "evt_6PJ1LM8T",
  "event": "ticket.deleted",
  "delivered_at": "2026-08-25T05:00:17.226Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "user", "id": "usr_a91f", "name": "Dana Reyes" },
  "ticket": { "id": "T-4F2A", "title": "Checkout 502s on payment retry", "…": "…" }
}

Custom buttons ​

A custom button is a label + URL pair that POSTs to that URL on click and shows the outcome to whoever clicked — use it for things a human decides to trigger, like "Sync to abc.com" or "Deploy", rather than something that should fire on every change (that is what webhooks are for).

Add one under Project settings → Ticket system → Integrations → Custom buttons: fill in a label, a full http(s):// URL, and where it shows.

PlacementWhere it drawsWhat it sendsEvent
On a ticket's page (default)Every ticket's own pageThat one ticketticket.action
In the ticket listThe board toolbar, beside the Assignee filterThe list's filter + the tickets it matchesticket.board_action

Pick by what the click is ABOUT. "Sync this ticket to Notion" belongs on the ticket; "pull everything assigned to me" belongs in the list, because the answer depends on who the toolbar is filtered to and no single ticket can carry that.

A button saved before placements existed is a ticket-page button — the field is absent and absent means the ticket page.

On a ticket's page ​

Clicking the button calls:

POST /api/tickets/{ticketID}/actions/{buttonID}

which delivers a ticket.action event to the button's own URL — same envelope shape as a webhook delivery, same headers (X-Wick-Event: ticket.action, X-Wick-Signature if the button carried a secret), and the same SSRF guard and retry schedule as a configured webhook. The event's action field carries the button's id, so one receiver serving several buttons can tell them apart:

json
{
  "id": "evt_4KQ8ZN2R",
  "event": "ticket.action",
  "action": "btn_9f3a1c2d4e5b6a7f",
  "delivered_at": "2026-08-26T04:11:09.412Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "user", "id": "usr_a91f", "name": "Dana Reyes" },
  "ticket": {
    "id": "T-4F2A",
    "project_id": "proj_7f21c9",
    "title": "Checkout returns 502 on retry",
    "body": "Reproduced on staging with retry enabled.",
    "status": "in_progress",
    "assignee": "usr_a91f",
    "fields": { "type": "bug", "priority": "high" }
  }
}

The click response mirrors the delivery outcome, not a generic "queued":

json
{ "ok": true, "status": 200, "error": "", "attempts": 1 }

ticket.action is not subscribable from a regular webhook row — it only ever reaches the button's own URL, because the button already names its receiver.

In the ticket list ​

A button placed in the list sits in the board's toolbar. Clicking it calls:

POST /api/projects/{projectID}/board-actions/{buttonID}

with the toolbar's current filter as the body — the same vocabulary the board itself uses:

json
{ "assignee": "me", "statuses": ["open", "in_progress"] }

assignee takes the board's own comma list ("me,usr_b22c,none") when the clicker ticked several people.

and delivers a ticket.board_action event to the button's URL. The envelope carries no ticket; it carries the list:

json
{
  "id": "evt_7XB2QK9L",
  "event": "ticket.board_action",
  "action": "btn_9f3a1c2d4e5b6a7f",
  "delivered_at": "2026-09-15T04:11:09.412Z",
  "project_id": "proj_7f21c9",
  "actor": { "type": "user", "id": "usr_a91f", "name": "Dana Reyes", "email": "dana@abc.com" },
  "board": {
    "assignee": "me",
    "assignee_filtered": true,
    "assignee_ids": ["usr_a91f"],
    "assignee_id": "usr_a91f",
    "assignee_name": "Dana Reyes",
    "assignee_email": "dana@abc.com",
    "statuses": ["open", "in_progress"],
    "match_count": 2,
    "tickets": [
      {
        "id": "T-4F2A",
        "title": "Checkout returns 502 on retry",
        "status": "in_progress",
        "assignee": "usr_a91f",
        "fields": { "type": "bug", "notion_page_id": "a1b2…" },
        "updated_at": "2026-09-15T03:58:02Z"
      },
      { "id": "T-51C7", "title": "…", "status": "open", "assignee": "usr_a91f", "updated_at": "2026-09-14T11:20:41Z" }
    ],
    "truncated": false
  }
}

Three things about board are worth reading closely:

  • assignee is what was picked; assignee_ids is who that is. "me" is resolved against the clicker before the event leaves wick, so a receiver never has to guess. assignee_none is true when unassigned tickets were picked. assignee_filtered says whether the list was narrowed by assignee at all — read it before anything else: false means every ticket. assignee_id (with assignee_name / assignee_email) is set only when exactly one person was picked, so an empty assignee_id means everyone only when assignee_filtered is false; with several people or none ticked it is empty too.
  • assignee_email (and actor.email) is how you find that person in YOUR system. A wick user id means nothing outside wick and a name is ambiguous; an email is the identifier both systems usually already have, so matching on it beats a hand-maintained id table. Absent when the user record has no email, and on every ticket.action where the actor is an API token.
  • match_count is the honest total. tickets is capped at 500 rows (newest first), and truncated says when the cap bit.
  • fields is the ticket's full field map, not the card's subset — a receiver's join key (a Notion page id, an external ticket number) is exactly the kind of field nobody marks show on card.

statuses is worth acting on rather than logging: it is the board as the clicker has it set up, and "every column except Done" is how most boards are read. A receiver that imports everything regardless will pull back the exact column the person switched off.

The click response reports the delivery, the number of tickets that matched, and the receiver's own reply:

json
{ "ok": true, "status": 200, "error": "", "attempts": 1,
  "message": "syncing Dana Reyes from Notion (2 in view)", "tickets": 2,
  "result": { "…": "the receiver's JSON body, verbatim" } }

A delivery gets 10 seconds per attempt, so a job that takes longer must answer immediately and keep working — otherwise wick times out and retries, which starts the job three times. What makes that bearable for the person who clicked is result: the board draws a panel from it instead of a toast.

The result panel ​

Everything below is optional. A receiver that answers {} still works; each key it does send lights up one more part of the panel.

KeyTypeWhat the panel does with it
statusstringrunning/queued/busy/started → amber "Running" + follows it; error/failed/refused → red; ignored/skipped → "Nothing to do"; anything else → green "Done"
messagestringThe sentence under the label
progress{done, total}Progress bar + done/total
countsobject of number/stringOne chip per entry, in the order given
poll_urlstringWhere to watch the run — see below
htmlstringFolded away behind a Details toggle, then rendered in a bare sandboxed frame (no scripts, no network). Send it only for something the card cannot draw — a table, a diff, a list of what moved. Repeating the status and counters there only makes the card taller

The panel is a small card in the bottom-right corner, not a section of the board: it never takes column space, and a run that ends well closes itself after an 8-second countdown (paused while the pointer is on it). A failure stays until it is dismissed — it is the one outcome somebody has to read.

Following a run. While status says running and a poll_url is present, the board polls it every 3 seconds through wick (never from the browser) and re-draws the panel from each answer:

POST /api/projects/{projectID}/board-actions/{buttonID}/poll
{ "url": "https://abc.com/hooks/sync/status" }

The URL must be on the same origin as the button — same scheme, same host and port — or the request is refused. A receiver that could send wick anywhere would turn a ticket button into an SSRF primitive with the server's network position; the private-address guard applies on top of that.

Polling stops as soon as the status is no longer a running one (or after 200 checks, ~10 minutes). The board re-reads its tickets every time the counters move, so cards travel to their new column while the job walks the list rather than after somebody presses reload.

A second click is not a second job

A receiver that is already working should answer the next click with its CURRENT state — status: "running" plus the counters — rather than starting a rival pass. That is the whole reason progress exists: an impatient click becomes a progress report.

One receiver, both buttons

The action field carries the button id on both events, and event says which kind it was. A receiver serving a ticket button and a list button on the same endpoint should branch on event — the board one has no ticket.

Verifying the signature ​

Verify the raw bytes

Compute the HMAC over the exact body you received. JSON key order and whitespace are not stable across languages, so parsing and re-serialising before verifying will produce a different digest and every check will fail. Compare with a constant-time function, never ==.

Node / Express

js
const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = process.env.WICK_WEBHOOK_SECRET;

// express.raw, not express.json: the signature covers the raw bytes.
app.post("/hooks/wick-tickets", express.raw({ type: "application/json" }), (req, res) => {
  const expected =
    "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const got = req.get("X-Wick-Signature") ?? "";

  if (
    expected.length !== got.length ||
    !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got))
  ) {
    return res.status(401).send("bad signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  console.log(req.get("X-Wick-Event"), event.ticket.id, event.changes);

  // Answer fast; do the work after. Wick gives up after ~30s of retries.
  res.sendStatus(202);
});

app.listen(3000);

Go

go
func handler(secret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "read", http.StatusBadRequest)
			return
		}
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(body)
		expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
		if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Wick-Signature"))) {
			http.Error(w, "bad signature", http.StatusUnauthorized)
			return
		}

		var ev struct {
			Event  string `json:"event"`
			Ticket struct {
				ID     string `json:"id"`
				Status string `json:"status"`
			} `json:"ticket"`
		}
		if err := json.Unmarshal(body, &ev); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		log.Printf("%s %s → %s", ev.Event, ev.Ticket.ID, ev.Ticket.Status)
		w.WriteHeader(http.StatusAccepted)
	}
}

Python / Flask

python
import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WICK_WEBHOOK_SECRET"].encode()

@app.post("/hooks/wick-tickets")
def hook():
    expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Wick-Signature", "")):
        abort(401)
    event = request.get_json()
    print(request.headers["X-Wick-Event"], event["ticket"]["id"])
    return "", 202

Receiver checklist ​

  • Answer in under 10s — do the real work asynchronously. Retries stop after ~30 seconds.
  • Dedupe on X-Wick-Delivery. A retry after a timeout can deliver an event your handler already processed.
  • Do not assume order. Deliveries are parallel; use delivered_at or re-read the ticket.
  • Ignore your own writes — filter actor.type == "api" to avoid loops.
  • Reconcile on startup if you cannot afford to miss an event.

Troubleshooting ​

Every attempt is recorded. Open the webhook row in Integrations → Recent deliveries for the status, the error, and the attempt count. Send test delivers a synthetic ticket.updated for T-TEST to the saved endpoint — save the row before testing it.

SymptomCause
webhook url resolves to a private address (…) — refusedThe URL points at loopback, a private range, or link-local. Wick refuses these by default: a webhook URL is fetched by the server, so allowing them would let it be pointed at internal services.
401 from your receiverSignature mismatch — almost always re-serialising the body before verifying.
Nothing arrivesRow disabled, event not in its filter, or ticket mode off for the project.
Two deliveries per changeSubscribed to both ticket.updated and a specific event. Pick one.
404 on every API callThe project's REST API toggle is off, or the token's user cannot see that project.

See also ​

Built with ❤️ by a developer, for developers.