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
404not_foundThe resource does not exist in your workspace
422validation_errorThe request was well-formed but the data is invalid
422incident_not_activeThe incident is resolved or canceled and cannot take this change
429rate_limit_exceededYou exceeded 1,000 requests per minute for this key

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
}
}

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

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, and each one needs incidents: update.

EndpointWhat it does
GET /api/v1/incidents/:id/action_itemsLists the open work, with who holds each piece
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_idTakes it, hands it over, or marks it done
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 or their Slack user ID rather than a Firefight ID. 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.

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 taking a piece of work, handing it over and finishing it, because each is the same sentence: this item now looks like this. Send assignee_id: null to take it yourself, name someone else to hand it over, and send status: "done" to finish it.

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
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. Incident forms are the 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.

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 of its own, on purpose. The version that beat you is not one you can write against, since you have not seen what it says.

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.

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", "rank": 2, "position": 1, "is_default": false },
{ "id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "SEV2", "slug": "sev2", "rank": 1, "position": 2, "is_default": true }
]
}

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", "lifecycle_stage": "active" },
"severity": { "id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "SEV2", "rank": 2 },
"type": null,
"lead": null,
"source": "datadog",
"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: 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.