Webhook payload structure for work_order.status_changed

Need the exact payload structure for work_order.status_changed. Docs show an example but no schema. Specifically:

  • Is previous_status always present?
  • What's the timestamp format — ISO 8601 with millis or no?
  • Are custom fields included or do I need a follow-up API call?

Current handler is breaking on null assignee_id when status goes from assignedunassigned. Need to know what else can be null.

// What I'm receiving (sanitized):
{
  "event": "work_order.status_changed",
  "timestamp": "2025-09-28T14:32:17.842Z",
  "data": {
    "work_order_id": "wo_...",
    "status": "in_progress",
    "previous_status": "assigned",
    "assignee_id": "usr_...",
    "changed_at": "2025-09-28T14:32:15.000Z"
  }
}

Show me the full schema or point me to OpenAPI/Swagger.

  • Hey Carlos — yeah this one tripped me up too when I first looked at it. The docs example is incomplete and I keep meaning to push for a proper JSON Schema. Here's what I can confirm from our side:

    Nullable fields (can be null or omitted):

    • previous_status — omitted entirely on new work orders (no prior state)
    • assignee_id — null when unassigned
    • team_id — null when not assigned to a team
    • scheduled_start / scheduled_end — null when no scheduling

    Timestamp format: Always ISO 8601 with milliseconds and Zulu offset (2025-09-28T14:32:17.842Z). The changed_at in your payload is the DB commit time; timestamp is when we fired the webhook (usually 50–200ms later).

    Custom fields: Not included in this event. You need to hit GET /v1/work_orders/{id} with ?include=custom_fields if you need them. There's a feature request to optionally include them in webhooks (API v2.1 added it for work_order.created but not status changes yet).

    Full schema I'm aware of:

    {
      "event": "work_order.status_changed",
      "webhook_id": "wh_...",
      "timestamp": string,  // ISO 8601 with ms
      "data": {
        "work_order_id": string,
        "status": string,           // new status
        "previous_status": string?, // omitted if no prior
        "assignee_id": string?,     // null if unassigned
        "team_id": string?,         // null if no team
        "customer_id": string,
        "location_id": string?,
        "scheduled_start": string?, // ISO 8601
        "scheduled_end": string?,   // ISO 8601
        "changed_at": string,       // when status actually changed
        "changed_by": {             // user or system
          "type": "user" | "system" | "api",
          "id": string?             // null for system
        }
      }
    }

    The ? denotes optional/nullable. Let me know if you're seeing fields not in that list — we had a brief regression in August where location_id was missing.

  • Worth noting: previous_status can also be null (not just omitted) in legacy event streams. Defensive check:

    const prev = payload.data.previous_status ?? null;

    Also Setting Up Webhooks has outdated example — missing changed_by entirely. Eli's schema above matches prod.

  • @eli.torres thanks — null vs omitted was the issue. previous_status missing on new WO caused my validator to throw.

    Confirmed changed_by.type === "system" during automated status transitions (SLA breach auto-escalation). id is null as expected.

    Still need that OpenAPI spec though. Maintaining hand-rolled validators is brittle.

  • Agreed on OpenAPI. Internal ticket is ENG-4421 — no ETA but it's in the Q4 API polish backlog. I'll poke the team.

    For now, here's the validator I use internally (zod) if it helps:

    const WebhookPayload = z.object({
      event: z.literal('work_order.status_changed'),
      webhook_id: z.string(),
      timestamp: z.string().datetime(),
      data: z.object({
        work_order_id: z.string(),
        status: z.string(),
        previous_status: z.string().optional().nullable(),
        assignee_id: z.string().nullable(),
        team_id: z.string().nullable(),
        customer_id: z.string(),
        location_id: z.string().nullable(),
        scheduled_start: z.string().datetime().nullable(),
        scheduled_end: z.string().datetime().nullable(),
        changed_at: z.string().datetime(),
        changed_by: z.object({
          type: z.enum(['user', 'system', 'api']),
          id: z.string().nullable()
        })
      })
    });

    YMMV on the datetime validation — we include millis but not all parsers require them.

  • Heads up: if you're using the work_order.status_changed event to trigger side effects (notifications, external syncs), watch out for rapid-fire status changes. We've seen cases where a bulk edit or script triggers assignedin_progresson_holdin_progress within seconds. Your webhook handler needs to be idempotent or you'll get race conditions.

    We ended up debouncing with a 5-second window keyed on work_order_id + changed_at. YMMV depending on your downstream systems.