Every webhook is an HTTP POST whose body is one JSON envelope. The envelope is the same for every event; its data object depends on the event type.
{
"id": "6c1e9f3a-2b7d-5a40-8e15-4f9b0c2d7a63",
"type": "item.created",
"apiVersion": "2026-10-16",
"occurredAt": "2026-10-17T14:03:11.482Z",
"organizationId": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10",
"origin": {
"kind": "member",
"actorId": "jordan.lee",
"correlationId": "b7e2c4d1-9a3f-4e8b-a6d2-5c1f0e9b3a74"
},
"data": { "item": { "...": "..." } }
}
| Field | Type | Description |
|---|---|---|
id | string (UUID) | The event's id. Unique per event and identical on every retry and replay, and the same for every endpoint that receives the event. It is also sent in the X-DevStride-Event-Id header. Deduplicate on it. |
type | string | item.created, item.status_changed, comment.created, sla.at_risk or sla.breached; ping for a test send. |
apiVersion | string | The contract version, currently "2026-10-16". See Compatibility. |
occurredAt | string (ISO-8601, UTC) | When the change happened in DevStride, to the millisecond. Not when the delivery was sent: a retried delivery keeps its original occurredAt. |
organizationId | string (UUID) | The organization the change happened in. |
origin | object | Who or what made the change. See Origin. |
data | object | The payload for this type, described below. |
origin tells you who caused the change, so you can route it, or drop the echo of a change your own integration made.
| Field | Type | Description |
|---|---|---|
kind | string | member, customer, api, system or automation (below). |
actorId | string or null | Who acted: a member's username for member and api, the requester id for customer, and null for system or whenever the change does not record who made it. |
correlationId | string | The id of the request or job that produced the change. Events caused by the same action share it, which lets you group them. |
kind | Means | Examples |
|---|---|---|
member | A person working in DevStride | An item created in the app; a status moved on a board; an internal note. |
customer | A Service Desk requester | A reply by email or in the customer portal; a requester moving their own ticket in the portal. |
api | A member writing through the public API | Today, only a comment a member (not a requester) wrote on the api channel. Most API writes read member; see below. |
system | DevStride itself | An SLA timer; a status reassigned mechanically after a board or status-collection change; a test ping. |
automation | Reserved for changes made by automation rules | Not sent yet. |
member, with your API key's member as actorId, because those changes do not record their channel. A comment written by an automation reads member, attributed to the automation's creator. A ticket created from an inbound email or the portal also reads member; use data.item.requesterId to recognize tickets. To drop echoes of your own writes reliably, run your integration as a dedicated member and filter on its username in actorId.item.createdFires when a work item is created: in the app, through the API or the DevStride MCP server, and when a Service Desk ticket arrives by email, from the portal or from a request form. It covers every work type, tickets included. Creating a workstream does not fire it.
The payload is a fixed subset of the item, not the whole record. Every key is always present; a value that is not set is null.
| Field | Type | Description |
|---|---|---|
data.item.id | string | The item's id, in the form <organizationId>:<number>. |
data.item.number | string | The item number shown in DevStride, for example I4821. |
data.item.title | string | The title. For a ticket from an inbound email, this is the email's subject. |
data.item.workTypeId | string | The item type's id. |
data.item.parentNumber | string | The number of the workstream or item it was created under, for example F37. |
data.item.statusId | string or null | Its status. |
data.item.boardId | string or null | The board it is on. |
data.item.priorityId | string | Its priority. |
data.item.assigneeUsername | string or null | The assignee's username. |
data.item.labels | array of strings | Label ids. Empty when it has none. |
data.item.requesterId | string or null | The Service Desk requester. Set only on a ticket: a non-null value is how you tell a ticket from ordinary work. |
data.item.channelAddressId | string or null | The inbound channel address a ticket arrived at. null for ordinary work. |
data.item.archived | boolean | Whether it was created archived. |
{
"id": "6c1e9f3a-2b7d-5a40-8e15-4f9b0c2d7a63",
"type": "item.created",
"apiVersion": "2026-10-16",
"occurredAt": "2026-10-17T14:03:11.482Z",
"organizationId": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10",
"origin": {
"kind": "member",
"actorId": "jordan.lee",
"correlationId": "b7e2c4d1-9a3f-4e8b-a6d2-5c1f0e9b3a74"
},
"data": {
"item": {
"id": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10:I4821",
"number": "I4821",
"title": "Invoice PDF download returns a blank page",
"workTypeId": "2d5b8e1f-7c3a-4b9e-8f20-6a1d4c7e9b35",
"parentNumber": "F37",
"statusId": "c3a9e7d2-1f4b-4e6a-9d8c-7b2e5f1a0c46",
"boardId": "a41f6c2e-8d3b-4f7a-b5e9-0c6d2a8f1e57",
"priorityId": "e9d2b6a4-3c7f-4a1e-8b5d-2f9c6e0a7d18",
"assigneeUsername": null,
"labels": ["5f3c8a1d-6e2b-4d9f-a7c4-1b8e3f6d2a90"],
"requesterId": "0b7d4e9a-5c2f-4a8e-9d1b-6e3f8c2a5d71",
"channelAddressId": "d8a5c2f7-4e1b-4c9d-8a6e-3f0b7d4c1e29",
"archived": false
}
}
}
The event deliberately leaves out descriptions, custom fields and everything else that changes from release to release. When you need more, read the item from the API by its number (GET /v1/organizations/{organizationId}/work-items/{itemNumber}; see the API reference).
item.status_changedFires when an item moves to a different status: dragged on a board, changed in the item, set through the API, MCP or an automation, moved by a requester in the portal, or reassigned by DevStride itself.
| Field | Type | Description |
|---|---|---|
data.itemId | string | The item's id, <organizationId>:<number>. |
data.itemNumber | string | The item number, for example I4821. |
data.previousStatusId | string or null | The status it left. |
data.newStatusId | string or null | The status it entered. |
data.previousStatusType | string or null | The kind of status it left: new, custom or done. |
data.newStatusType | string or null | The kind of status it entered: new, custom or done. A move into done is how you detect completion. |
data.boardId | string or null | The board the item is on. |
data.cause | string | Why it moved (below). |
cause | Means | origin.kind |
|---|---|---|
direct | It was moved deliberately: by a member in the app, through the API or MCP, or by an automation. | member, with actorId when the change records who did it, otherwise null |
customer-self-service | The requester moved their own ticket in the customer portal. | customer, with actorId null |
board-remap | DevStride re-homed it because the item was attached to, detached from or moved between boards. | system |
migration | DevStride reassigned it mechanically, for example after its status was removed from a collection. | system |
{
"id": "9a4d7e2c-6f1b-5c83-b0e9-3d5a8c1f6e27",
"type": "item.status_changed",
"apiVersion": "2026-10-16",
"occurredAt": "2026-10-17T15:21:40.007Z",
"organizationId": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10",
"origin": {
"kind": "member",
"actorId": "jordan.lee",
"correlationId": "e1c8a5f2-7d3b-4e9a-b6c1-8f4d2a7e0b93"
},
"data": {
"itemId": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10:I4821",
"itemNumber": "I4821",
"previousStatusId": "c3a9e7d2-1f4b-4e6a-9d8c-7b2e5f1a0c46",
"newStatusId": "7e2f9b4c-0a6d-4c1e-9f3b-5d8a2c6e1f04",
"previousStatusType": "new",
"newStatusType": "custom",
"boardId": "a41f6c2e-8d3b-4f7a-b5e9-0c6d2a8f1e57",
"cause": "direct"
}
}
To show status names rather than ids, read the organization's statuses once from the API and cache them.
comment.createdFires when a comment is added to a work item or ticket, or a reply is added under one. It fires for public replies and internal notes alike, whichever way they arrived:
| How the comment was written | channel | visibility | origin.kind |
|---|---|---|---|
| A member in the DevStride app (a reply or an internal note) | null | public or internal | member |
| A requester replying by email | email | usually public | customer |
| A requester replying in the customer portal | portal | public | customer |
channel can also read api for a message that came in through the Service Desk API, and sms is reserved for text messages. Comments on Knowledge Base articles do not fire the event.
"visibility": "internal". If anything you build shows comments to customers or partners (a CRM timeline, a shared Slack channel), filter on visibility first: only public comments are customer-facing.| Field | Type | Description |
|---|---|---|
data.comment.id | string (UUID) | The comment's id. |
data.comment.itemNumber | string | The item or ticket it belongs to, for example I4821. For a reply, the item of the comment it replies to. |
data.comment.visibility | string | public (customer-visible on a ticket) or internal (staff only). |
data.comment.channel | string or null | email, sms, portal or api: the channel it arrived on or was sent through. null for a comment written in the DevStride app. |
data.comment.authorUsername | string | The member who wrote it. For a requester's comment this is DevStride's own system account, not a person: use externalAuthorId. |
data.comment.externalAuthorId | string or null | The requester who wrote it; null when a member wrote it. |
data.comment.fromExternalAuthor | boolean | true when a requester wrote it. |
data.comment.html | string | The comment body as HTML. Empty when truncated is true. |
data.comment.truncated | boolean | true when the body is larger than 256 KB and was left out. Read the comment from the API to get it. |
data.comment.attachments | array of { "url": string } | Files and images stored in DevStride that the HTML links to or embeds: the same public URLs customer emails use. Links to other websites are not listed. Still present when truncated is true. |
data.comment.createdAt | string (ISO-8601) | When the comment was written. |
A requester's reply by email, with a screenshot:
{
"id": "2f6c1d0a-8b4e-5c3f-9a71-0e5d4c3b2a19",
"type": "comment.created",
"apiVersion": "2026-10-16",
"occurredAt": "2026-10-17T16:45:02.318Z",
"organizationId": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10",
"origin": {
"kind": "customer",
"actorId": "0b7d4e9a-5c2f-4a8e-9d1b-6e3f8c2a5d71",
"correlationId": "f3b9d6a1-2c8e-4f7b-a0d5-9e1c4b7f2a68"
},
"data": {
"comment": {
"id": "4e8b1f6d-3a9c-4d2e-b7f0-1c5a8e3d9b42",
"itemNumber": "I4821",
"visibility": "public",
"channel": "email",
"authorUsername": "devstride",
"externalAuthorId": "0b7d4e9a-5c2f-4a8e-9d1b-6e3f8c2a5d71",
"fromExternalAuthor": true,
"html": "<p>Still blank for me, screenshot attached.</p><p><img src=\"https://media.devstride.com/8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10/comments/blank-invoice.png\"></p>",
"truncated": false,
"attachments": [
{ "url": "https://media.devstride.com/8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10/comments/blank-invoice.png" }
],
"createdAt": "2026-10-17T16:45:02.301Z"
}
}
}
An internal note from a member differs only where you would expect: "visibility": "internal", "channel": null, the member's authorUsername, "externalAuthorId": null, "fromExternalAuthor": false, and an origin of { "kind": "member", "actorId": "<username>" }.
sla.at_risk and sla.breachedFire for Service Desk tickets that an SLA policy is timing:
sla.at_risk fires when a timer enters the last 20% of its allowed time without being met: your warning that a promise is about to be broken.sla.breached fires when a target passes before it was met.Timers count in the policy's business hours, so these fire on that clock. Both events carry the same three facts and always have origin.kind system with actorId null.
| Field | Type | Description |
|---|---|---|
data.itemNumber | string | The ticket, for example I4821. |
data.metric | string | Which promise: FIRST_RESPONSE, NEXT_RESPONSE or RESOLUTION. |
data.targetAt | string (ISO-8601) | When the target is (or was) due. |
{
"id": "d7b3e1a9-4c6f-5e2d-8a0b-6f9c3e5d1a84",
"type": "sla.breached",
"apiVersion": "2026-10-16",
"occurredAt": "2026-10-17T18:00:00.512Z",
"organizationId": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10",
"origin": {
"kind": "system",
"actorId": null,
"correlationId": "a6e0c3f8-5b2d-4a9e-81c7-4d6f9b2e0a35"
},
"data": {
"itemNumber": "I4821",
"metric": "FIRST_RESPONSE",
"targetAt": "2026-10-17T18:00:00.000Z"
}
}
An sla.at_risk event has exactly the same shape, with "type": "sla.at_risk" and a targetAt still in the future.
pingping is sent only by the Test button (or the test route of the API). It is signed like any other event, is not stored in the delivery log and is never retried. Its data is { "endpointId": "<the endpoint's id>" } and its origin.kind is system. You cannot subscribe to it; accept it with a 2xx and ignore it.
The five types above are the complete list today. In particular there is no event for:
Changes that load or correct history rather than happen now (for example items imported with their original creation dates) are not delivered either: a backfill of ten thousand items would otherwise send ten thousand webhooks.
The contract only ever grows:
2xx to an event type you do not handle: never reject it.apiVersion, announced ahead of time.Write your parser to read the fields it needs and tolerate everything else, and new releases will never break it.
Quickstart
Build a receiver that verifies DevStride's signature, register it under Settings → Webhooks, copy the one-time signing secret, send a test event and watch real deliveries arrive in the delivery log.
Verifying Signatures
How DevStride signs every webhook with HMAC-SHA256, the exact string it signs, the headers it sends, replay protection, secret rotation and raw-body pitfalls, with tested verification code in Node.js, Python, Go, Ruby, PHP and C#.