Using the API
This page covers the mechanics you need for reliable integrations. It assumes you already have a key from API overview.
Idempotent incident creation
Section titled “Idempotent incident creation”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.
| Situation | Response |
|---|---|
| First request with a key | 201 Created, a new incident |
| Repeated request with the same key | 200 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.
Errors
Section titled “Errors”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.
| Status | type | When |
|---|---|---|
| 400 | bad_request | A required parameter is missing |
| 401 | unauthorized | The token is missing, invalid, expired, or deactivated |
| 403 | forbidden | The token lacks the permission this endpoint checks |
| 404 | not_found | The resource does not exist in your workspace |
| 422 | validation_error | The request was well-formed but the data is invalid |
| 422 | incident_not_active | The incident is resolved or canceled and cannot take this change |
| 429 | rate_limit_exceeded | You exceeded 1,000 requests per minute for this key |
Pagination
Section titled “Pagination”List endpoints that can grow large, such as incidents and catalog entries, accept two query parameters.
| Parameter | Default | Maximum |
|---|---|---|
page | 1 | none |
per_page | 25 | 100 |
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.
Reading an incident’s timeline
Section titled “Reading an incident’s timeline”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.
Taking part in an incident
Section titled “Taking part in an incident”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.
| Endpoint | What it does |
|---|---|
GET /api/v1/incidents/:id/action_items | Lists the open work, with who holds each piece |
POST /api/v1/incidents/:id/action_items | Adds a piece of work and posts it in the channel |
PATCH /api/v1/incidents/:id/action_items/:item_id | Takes it, hands it over, or marks it done |
POST /api/v1/incidents/:id/escalate | Asks a named person to pick the incident up |
POST /api/v1/incidents/:id/invite | Brings people into the incident channel |
POST /api/v1/incidents/:id/link | Links two incidents, or marks one a duplicate |
POST /api/v1/incidents/:id/shoutout | Thanks someone in the channel |
POST /api/v1/incidents/:id/runbook_steps/claim | Takes 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.
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.
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.
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.
Configuring your workspace
Section titled “Configuring your workspace”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.
| Resource | Endpoints | Addressed by |
|---|---|---|
| Severities | /api/v1/severities | slug |
| Statuses | /api/v1/statuses | slug |
| Incident types | /api/v1/incident_types | slug |
| Incident roles | /api/v1/incident_roles | slug |
| Custom fields | /api/v1/custom_fields | slug |
| Incident forms | /api/v1/forms | form slug |
| Runbooks | /api/v1/runbooks | ID or slug |
| Catalog types | /api/v1/catalog/types | slug |
| Alert sources | /api/v1/alert_sources | endpoint path |
| Alert routing rules | /api/v1/routing_rules | priority within its scope |
| Webhooks | /api/v1/webhooks | ID |
| Service keys | /api/v1/api_keys | prefix |
| Agents | /api/v1/agents | slug |
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.
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.
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 fields, forms and runbooks
Section titled “Custom fields, forms and runbooks”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.
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.
Alert routing rules
Section titled “Alert routing rules”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.
Postmortems
Section titled “Postmortems”| Endpoint | What it does |
|---|---|
GET /api/v1/incidents/:id/postmortem | The write-up, as HTML, with its status and who wrote it |
POST /api/v1/incidents/:id/postmortem | Opens the postmortem, empty or drafted by AI |
PATCH /api/v1/incidents/:id/postmortem | Replaces 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.
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.
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.
Reading an incident’s conversation
Section titled “Reading an incident’s conversation”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.
Agents and service keys
Section titled “Agents and service keys”/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.
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.
Worked example: declare, then resolve
Section titled “Worked example: declare, then resolve”First fetch the reference data you need. Severities, statuses, and types are specific to your workspace, so their IDs are too.
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.
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.
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.