Skip to content

This page covers the mechanics you need for reliable integrations. It assumes you already have a key from API overview.

POST /api/v1/incidents requires an idempotency_key in the request body. It is any string you choose that uniquely identifies this creation attempt, for example your alert’s ID or a UUID you generate.

The key protects you from duplicates when a request times out and you retry.

SituationResponse
First request with a key201 Created, a new incident
Repeated request with the same key200 OK, the incident created the first time

Idempotency keys expire after 24 hours. After that, reusing the same key creates a new incident, so treat keys as protection for retries, not as a long-term deduplication mechanism.

Every error is JSON with a single error object. The request_id identifies the request, so include it when you contact support.

{
"error": {
"type": "validation_error",
"message": "Validation failed: Name can't be blank",
"request_id": "9f2c1d4e-8a7b-4c3d-b5e6-0a1b2c3d4e5f",
"errors": [
{ "field": "name", "message": "can't be blank" }
]
}
}

The errors array appears only on validation errors and lists each failing field.

StatustypeWhen
400bad_requestA required parameter is missing
401unauthorizedThe token is missing, invalid, expired, or deactivated
403forbiddenThe token lacks the permission this endpoint checks
403transcript_access_disabledThe workspace has not turned transcript access on
403workspace_suspendedThe workspace cannot be used right now, and the message says why
404not_foundThe resource does not exist in your workspace
409stale_contentThe postmortem changed since the version you sent
409conflictSomeone changed the handbook page or skill since the version you sent
422validation_errorThe request was well-formed but the data is invalid
422incident_not_activeThe incident is resolved or canceled and cannot take this change, or a settings entry cannot be disabled or deleted, and the message says why
422incidents_blockedThe workspace has not connected Slack yet, so it cannot declare an incident
422version_requiredYou sent a postmortem body without the version you read
422page_syncedThe handbook page is synced from a repository or a document, so it changes only at its source
422skill_refusedThe skill names a tool Halon does not have, or cannot be changed as asked
422approval_not_allowedThe approval is no longer pending, or you are not one of its approvers
429rate_limit_exceededYou exceeded 1,000 requests per minute for this key

A call that an approval rule holds is not an error. It comes back as 202 Accepted with the error type approval_required and an approval_id beside it, and nothing changes. Once someone approves it, send the identical request again with an X-Approval-Id header carrying that id.

A change to permissions, member access, API keys, agents and their tokens, approvals or webhooks always comes back this way, whatever your token was granted and whatever the approval rules say. An admin approves it on the Approvals page or in Slack, and then you send it again. Reading them never waits.

List endpoints that can grow large, such as incidents and catalog entries, accept two query parameters.

ParameterDefaultMaximum
page1none
per_page25100

Every paginated response includes a pagination object.

{ "pagination": { "page": 2, "per_page": 25, "total": 132, "total_pages": 6 } }

The incident list also accepts filters as query parameters. Use severity_id, status_id, or lifecycle_stage with one of triage, active, closed, or canceled. Look up the IDs from GET /api/v1/severities and GET /api/v1/statuses.

GET /api/v1/incidents/:id/timeline returns everything recorded against an incident in order, paginated like any other list. Each event carries its type, a plain-language description, who did it, and when.

Events that Firefight noted from the channel also carry a milestone object. That is how you read how an incident was debugged without touching Slack.

{
"event_type": "milestone.noted",
"description": "noted Diego identified the migration lock as the root cause",
"automated": true,
"occurred_at": "2026-08-25T14:31:00Z",
"actor": null,
"milestone": {
"kind": "root_cause",
"statement": "Diego identified the migration lock as the root cause",
"said_by": "Diego",
"said_at": "2026-08-25T14:31:00Z",
"message_text": "found it, the migration is holding the lock",
"permalink": "https://yourteam.slack.com/archives/C123/p1756132260",
"dismissed_at": null
}
}

An update or a cancellation also carries an update object with the message that was posted, as markdown, and each field it changed with its value before and after. Every other event has update: null.

{
"event_type": "incident.updated",
"description": "updated the incident",
"update": {
"message": "Rolled back the 14:02 deploy.\n\n- Errors are back to baseline",
"changes": [
{ "field": "severity", "label": "Severity", "before": "Major", "after": "Minor" }
]
},
"milestone": null
}

Every event that is not a note has milestone: null. The kind is one of hypothesis, finding, root_cause, mitigation, decision, blocker, impact, or recovery.

A step of a fix that went through, failed or was marked done carries a fix_step object with the step’s number, its name, its result in plain words, and the link to what it produced when there is one. Every other event has fix_step: null.

{
"event_type": "fix.step_done",
"description": "ran step 1 of the fix",
"fix_step": {
"position": 1,
"title": "Open a pull request with the change",
"result": "Opened a pull request: PR #856",
"link_label": "PR #856",
"link_url": "https://github.com/acme/checkout/pull/856"
}
}

The fix’s other events are fix.applied, fix.step_failed, fix.step_marked_done, fix.cancelled, fix.ended and fix.undo_written. When an investigation finds an answer, its event carries an answer object with the answer in plain words: headline, sureness, what_is_wrong, suggested_fix, and the full answer. Every other event has answer: null.

To remove a note that reads a conversation wrong, PATCH /api/v1/incidents/:id/timeline/:note_id/dismiss with an incidents: update key. It returns the dismissed note, and that note stops appearing in the timeline afterwards. Anything that is not a note is refused with a 422. See Timeline notes.

Changing an incident’s status is PATCH /api/v1/incidents/:id. Everything else a responder does inside an incident has its own endpoint. Listing the work needs incidents: read, and every endpoint that changes something needs incidents: update.

EndpointWhat it does
GET /api/v1/incidents/:id/action_itemsLists every piece of work raised on it, open or done, with who holds each one
POST /api/v1/incidents/:id/action_itemsAdds a piece of work and posts it in the channel
PATCH /api/v1/incidents/:id/action_items/:item_idRenames it, takes it, hands it over, marks it done, reopens it, or lets go of it
POST /api/v1/incidents/:id/escalateAsks a named person to pick the incident up
POST /api/v1/incidents/:id/inviteBrings people into the incident channel
POST /api/v1/incidents/:id/linkLinks two incidents, or marks one a duplicate
POST /api/v1/incidents/:id/shoutoutThanks someone in the channel
POST /api/v1/incidents/:id/runbook_steps/claimTakes one step of an attached runbook

Anywhere you name a person, pass their email address, their Slack user ID, or their member ID from GET /api/v1/principals. A name that matches nobody in the workspace returns a 404.

Creating a piece of work takes a description, an optional kind of action or followup, and an optional assignee_id to hand it straight to someone. When your workspace opens an issue for every item of that kind, creating it opens its issue too, the same as raising it from Slack or the dashboard. Each item carries external_key and external_url once it has an issue, and issue_status says why its issue is missing or not kept in step. Renaming, finishing, reopening or handing over an item that tracks an issue changes the issue to match. See Issue tracking.

Terminal window
curl -X POST https://app.firefight.app/api/v1/incidents/0d9b2c1e-7f3a-4b8e-9c5d-2a6e8f1b4d70/action_items \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "description": "Drain replica 2", "kind": "action" }'

One PATCH covers renaming a piece of work, taking it, handing it over, finishing it, reopening it and letting go of it. Send description to rename it, assignee_id: null to take it yourself, name someone else to hand it over, status: "done" to finish it, and status: "open" to reopen a finished item (it goes back to whoever held it) or to let go of one in progress so nobody holds it. A title can be up to 3,000 characters, and a longer one is refused with how long it is.

Terminal window
curl -X PATCH https://app.firefight.app/api/v1/incidents/0d9b2c1e-7f3a-4b8e-9c5d-2a6e8f1b4d70/action_items/6b1f0a29-4c7d-4e1a-9f2b-3d8c5e7a0b14 \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "status": "done" }'

Escalating asks one named person to respond, posts the ask in the channel, messages them directly with an acknowledge button, and reminds them if they do not answer. Inviting is the quieter one, it just brings people in.

Terminal window
curl -X POST https://app.firefight.app/api/v1/incidents/0d9b2c1e-7f3a-4b8e-9c5d-2a6e8f1b4d70/escalate \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "member_id": "priya@example.com", "reason": "Needs a database owner" }'

Linking takes other_incident_id and a relationship of related or duplicate. related records the link on both timelines and changes nothing else. duplicate also cancels this incident, naming the one that absorbed it, so a resolved or canceled incident has to be reopened first.

Every settings screen has a matching set of endpoints, so a script can set a workspace up the same way a person does by clicking.

ResourceEndpointsAddressed by
Severities/api/v1/severitiesslug
Statuses/api/v1/statusesslug
Incident types/api/v1/incident_typesslug
Incident roles/api/v1/incident_rolesslug
Custom fields/api/v1/custom_fieldsslug
Incident forms/api/v1/formsform slug
Runbooks/api/v1/runbooksID or slug
Handbook pages/api/v1/handbook/pagesID
Skills/api/v1/skillsID
Workflows/api/v1/workflowsID or slug
Catalog types/api/v1/catalog/typesslug
Alert sources/api/v1/alert_sourcesendpoint path
Alert routing rules/api/v1/routing_rulespriority within its scope
Webhooks/api/v1/webhooksID
Service keys/api/v1/api_keysprefix
Agents/api/v1/agentsslug

Each takes GET and POST on the collection, and PATCH and DELETE on one member. Runbooks, handbook pages, skills and catalog types also take GET on one member. Workflows take only PATCH, for their settings. Incident forms are the other exception, since the four forms always exist and cannot be created or removed. GET /api/v1/forms/:slug reads one, where the slug is declare, update, resolve or cancel, and PATCH changes one field on it.

Creating a severity needs a name. Position is what orders them, first being the most severe, so a new top severity takes position 1. Leave it out to add one at the bottom. Severities, statuses, incident types and incident roles all take position the same way, on create and on update, and a position past either end lands on that end.

Terminal window
curl -X POST https://app.firefight.app/api/v1/severities \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "name": "SEV0", "position": 1, "color": "#e5484d", "description": "Everything is on fire." }'

Renaming never moves the slug, because the slug is what your existing incidents point at. Send PATCH with the slug in the path and whatever you want changed.

Terminal window
curl -X PATCH https://app.firefight.app/api/v1/severities/sev0 \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "name": "SEV0 Critical", "enabled": false }'

enabled: false retires an entry without deleting it. It keeps its slug, stops being offered to responders, and the incidents already using it are untouched. DELETE is refused with a 422 while anything still points at the entry, and the message says how many, so disabling is what you want in that case.

A listing leaves disabled entries out, matching what responders are offered. Add ?include_disabled=true when you are managing the list and need to see something in order to turn it back on.

A status also needs lifecycle_stage, one of triage, active, closed or canceled, which is what decides whether that status means the incident is live or over.

Custom field options are matched by label, so resending a list renames rather than replaces, and the incidents already holding an option keep pointing at it. See Custom fields.

A form is changed one field at a time. Name the field with either custom_field or system_field, then send what you want changed.

Terminal window
curl -X PATCH https://app.firefight.app/api/v1/forms/declare \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "custom_field": "affected_service", "visible": true, "required": true }'

Sending conditions replaces the conditions on that field rather than adding to them, and passing [] clears them. Severity and Status refuse to be hidden or made optional, exactly as they do on the dashboard. See Incident forms.

Runbook steps and conditions are only touched when you send them, so changing a summary never silently clears the procedure. Where workflows are on, a step that names a tool is written into the workflow the runbook links rather than onto the runbook, which also needs the workflows create permission. That workflow waits as a draft until someone publishes it from its page, and the response says so under workflow. See Runbooks.

PATCH /api/v1/workflows/:id changes a workflow’s settings, the same ones its Settings page holds, by its ID or slug: name, description, owner_team_id, cost_cap_cents (the spending ceiling per run, AI and sandbox time together, in cents), runs_per_hour (most runs per hour, 1 to 1,000), subject_key (one live run per), freeze_behaviour (wait or stop) and all_clear. Only what you send changes, and null clears the spending ceiling, the owning team or one live run per. It needs the workflows update permission, which only admins hold by default. See Workflows.

Terminal window
curl -X PATCH https://app.firefight.app/api/v1/workflows/restart_web \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "runs_per_hour": 250, "cost_cap_cents": 500 }'

The answer is the workflow’s settings after the change. A value the page would refuse, such as runs_per_hour of 0, is a 422 naming the field.

A routing rule belongs to a scope, which is either the workspace or one alert source, and is addressed by its priority within that scope. Pass source with the alert source name to work on one source, and leave it out for the workspace rules that every source without its own falls back to.

Writing the first rule for a source is what gives that source its own set of rules. New rules go on the end, and deleting one leaves a gap rather than moving the rules below it up, so read the list back before addressing another rule by priority.

POST /api/v1/routing/evaluate is a dry run. It takes hypothetical alert fields and returns the rule that matched, the outcome, a per-condition trace, and warnings when a routing role your rules depend on is not set on any catalog attribute. Nothing is created and nobody is notified. See Testing your routing.

EndpointWhat it does
GET /api/v1/incidents/:id/postmortemThe write-up, as HTML, with its status and who wrote it
POST /api/v1/incidents/:id/postmortemOpens the postmortem, empty or drafted by AI
PATCH /api/v1/incidents/:id/postmortemReplaces the body, changes the status, or both

Pass generate: true when creating to have Firefight draft the first version from the incident. That takes a moment, so the response comes back with a generation_state to poll on. Read it back until the state clears.

Terminal window
curl -X POST https://app.firefight.app/api/v1/incidents/3f8c1a90-2b7e-4d15-9c04-6ea5b3d71f28/postmortem \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "generate": true }'

Sending html replaces the whole document rather than appending, so read the current one first if you mean to add to it. Every version is kept. status moves it to draft, in_progress, in_review or completed, and needs no version.

Reading a postmortem gives you a version, and sending a body means sending that version back with it. If somebody edited the postmortem in between, your write is refused with a 409 rather than throwing their work away, and you read again and reapply your edit. A body sent with no version at all is refused with a 422, since there is no way to tell whether it was built on the current document.

Terminal window
curl -X PATCH https://app.firefight.app/api/v1/incidents/3f8c1a90-2b7e-4d15-9c04-6ea5b3d71f28/postmortem \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "html": "<h2>What happened</h2><p>The pooler ran out.</p>", "version": 3 }'

A refusal carries no version, so read the postmortem again before you write.

Starting one is refused with a 422 while the incident is still open, for a canceled incident, and for an incident that already has one. The message says which. All three endpoints check incidents.

GET /api/v1/incidents/:id/transcript returns what people said in the incident channel, oldest last, each message with who said it, when, the text, and whether anything in it was redacted.

This checks the Incident Transcripts ability rather than incidents, and also needs the workspace to have turned transcript access on. Without either it comes back as a 403 naming which is missing. See Incident conversations.

Pass limit for up to 500 messages, 100 by default. The response carries a more_before cursor, which you pass back as before to walk further into the past, and which comes back empty once there is nothing older.

/api/v1/agents and /api/v1/api_keys check admin-only permissions that cannot be granted to anyone, so an admin’s personal token reaches them and a service key or agent never can. An agent cannot create another agent or widen its own access.

Creating either one returns its token in that response and never again.

Terminal window
curl -X POST https://app.firefight.app/api/v1/agents \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "name": "Support agent", "description": "Triages tickets and opens incidents." }'
{
"id": "3f8c1a90-2b7e-4d15-9c04-6ea5b3d71f28",
"name": "Support agent",
"slug": "support_agent",
"description": "Triages tickets and opens incidents.",
"enabled": true,
"granted_abilities": 0,
"tokens": [{ "prefix": "ff_4kWm2xPqR", "created_at": "2026-08-26T14:02:00Z", "last_used_at": null }],
"token": "ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA"
}

A prefix is the first twelve characters of the token, which is how you name one to revoke it.

The new agent holds no abilities at all. Grant it what it needs under Permissions, or with the /api/v1/grants endpoints.

POST /api/v1/agents/:slug/rotate issues a second token while the first keeps working, so the agent stays up while you update its configuration. DELETE /api/v1/agents/:slug/tokens/:prefix ends one when you are ready. See Agents.

Sending permissions to /api/v1/api_keys replaces the whole set rather than adding to it, so read the current one from GET /api/v1/api_keys first. A listing never carries a token.

Everything under Gateway → Permissions has endpoints too. They check permissions, which only admins hold, so use an admin’s personal token. Approving and denying check approvals instead.

EndpointWhat it does
GET /api/v1/abilitiesEverything that can be granted, with its risk level
GET /api/v1/principalsThe people, agents and service keys of the workspace, each with the grants it holds
/api/v1/permission_setsLists, creates, changes and deletes permission sets, by slug
/api/v1/grantsLists, grants, rescopes and revokes grants, by ID
GET /api/v1/member_accessWhat members can read and change in each connected tool, as the Who can do what rows show it
PATCH /api/v1/member_access/:permission_setSets one of those rows to everyone, nobody or chosen
GET /api/v1/approval_rulesLists approval rules, in the order they are tried
GET /api/v1/unattended_rulesLists the changes Halon may make on its own
GET /api/v1/allowancesLists the risky changes Halon may make without asking in a chat. Adding, changing and deleting them is refused with a token
GET /api/v1/approvalsRequests an approval rule held, newest first, which you can approve or deny by ID. Each names who asked in principal_name
GET /api/v1/activityThe activity log, newest first. Each row names who made it in principal_name

Approval rules and the changes Halon may make on its own are changed only by a person, on the dashboard or by confirming it in a Halon chat. A change sent with a token is refused with token_refused, so no token can loosen the rules that hold its own calls. A request that was made with a token, over the API or MCP, is approved only by a person on the Approvals page or in Slack, and approving it over the API is refused. Once a request is approved, repeat it within an hour, or the approval lapses and the request asks again.

Each connected tool has a read row and a change row, and each row names the permission_set you send to change it. everyone lets every member, including people who join later. nobody takes it from every member. chosen takes a people list and grants the row to just those members, revoking it from everyone else. Admins can always do everything.

Terminal window
curl -X PATCH https://app.firefight.app/api/v1/member_access/northflank_changes \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "choice": "chosen", "people": ["priya@example.com"] }'

A permission set Firefight keeps in step with a connection’s tools is marked built_in, and changing or deleting one by hand is refused with a 422. See Permissions and Approvals.

First fetch the reference data you need. Severities, statuses, and types are specific to your workspace, so their IDs are too.

Terminal window
curl https://app.firefight.app/api/v1/severities \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA"
{
"severities": [
{ "id": "c7e2f9a1-3b4d-4c6e-8f0a-5d6b7c8e9f01", "name": "SEV1", "slug": "sev1", "description": "Customers cannot use the product.", "position": 1, "enabled": true, "color": "#e5484d", "is_default": false, "rank": 2 },
{ "id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "SEV2", "slug": "sev2", "description": "Part of the product is degraded.", "position": 2, "enabled": true, "color": "#f5a524", "is_default": true, "rank": 1 }
]
}

position is the order you set, first being the most severe. rank is derived from that order, so a higher rank means a more severe incident. You cannot set rank directly.

Now declare the incident. Only name, severity_id, and idempotency_key are required. Omitting status_id uses your workspace’s default status. You can also pass incident_type_id, declared_by_id with a member’s ID, visibility as public or private, custom_fields as a key-value object, and a free-form source string that tells responders where the incident came from. Custom fields are checked against your declare form, so an unknown key, an invalid value, or a missing required field returns a validation_error instead of creating the incident.

Terminal window
curl -X POST https://app.firefight.app/api/v1/incidents \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "datadog-monitor-771204-1626601964",
"name": "Checkout latency spike in eu-west",
"summary": "p99 latency on the checkout service exceeded 4s.",
"severity_id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"source": "datadog"
}'

The response is 201 Created with the full incident.

{
"incident": {
"id": "0d9b2c1e-7f3a-4b8e-9c5d-2a6e8f1b4d70",
"identifier": "INC-42",
"name": "Checkout latency spike in eu-west",
"summary": "p99 latency on the checkout service exceeded 4s.",
"status": { "id": "b1f6a2d4-8c3e-4f5a-9b7d-1e2c3a4b5d6f", "name": "Investigating", "slug": "investigating", "lifecycle_stage": "active" },
"severity": { "id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "SEV2", "slug": "sev2", "rank": 1 },
"type": null,
"lead": null,
"source": "datadog",
"test": false,
"declared_by": null,
"declared_at": "2026-07-18T09:12:44Z",
"detected_at": null,
"resolved_at": null,
"created_at": "2026-07-18T09:12:44Z",
"updated_at": "2026-07-18T09:12:44Z",
"custom_fields": {},
"visibility": "public"
}
}

Update it with PATCH /api/v1/incidents/:id. You can change name, summary, status_id, severity_id, incident_type_id, or assign a lead with lead_id, in any combination. Every field in the request lands in one change. Moving the incident to a status in the closed stage resolves it, moving it to a canceled-stage status cancels it, and moving a resolved or canceled incident back to a triage or active status reopens it.

A resolved incident cannot be canceled directly, and a canceled one cannot be resolved. Reopen it first. The request comes back as incident_not_active with the reason.

lead_id only works while the incident is live, and an incident that has a lead keeps one, so sending lead_id as null is refused. Assigning a lead announces it in the incident channel and tells the person directly, so on a resolved or canceled incident the request comes back as incident_not_active. Reopen the incident first if the response is genuinely starting again.

Terminal window
curl -X PATCH https://app.firefight.app/api/v1/incidents/0d9b2c1e-7f3a-4b8e-9c5d-2a6e8f1b4d70 \
-H "Authorization: Bearer ff_4kWm2xPqR8vNcT6yBhJd3fLzGaU9sEnQoXwA" \
-H "Content-Type: application/json" \
-d '{ "status_id": "f0e1d2c3-b4a5-4968-8776-655443322110", "summary": "Rolled back the 14:02 deploy. Latency recovered." }'

The response is 200 OK with the updated incident, including its resolved_at timestamp.