Deployments API
Declare deployments over HTTP with a deploy key, including request fields, limits, status updates, merging and response codes.
Send one HTTP request from your pipeline and Unhandled records a deployment: who shipped which version of which service, when it started, and how it ended. Charts across the app then show a marker at that moment, so a latency jump or a new issue lines up with the release that caused it.
The unhandled deploy command calls this endpoint for you and fills most fields from your CI environment. It needs Node 24 or later. Call the API yourself from other runtimes, or when you want to control every field. Deployments explains how declared deployments relate to the ones Unhandled detects from telemetry.
Authentication
Every request carries a deploy key in the x-api-key header. An admin creates deploy keys for a project under Settings > Deploy keys. A key starts with dk_, appears once when you create it, and can be revoked from the same page. The key decides the project, so the URL never names one.
/api/v1/deploymentsDeclare a deploymentThe examples use https://unhandled.net, the CLI's default base URL. A self-hosted instance serves the same path on its own URL.
Headers
x-api-keystringrequiredA deploy key that starts with
dk_.content-typestringrequiredapplication/json.x-unhandled-sourcestringSend
clito record the deployment with the sourcecli. Any other value, or no header, recordsapi. The CLI sets it for you.
Body
servicestringrequiredThe service's telemetry name, the
service.nameit reports. 1 to 120 characters. A name Unhandled has not seen yet is accepted and stored as sent, so you can declare a deployment before the service sends any telemetry.environmentstringrequiredproduction,stagingordevelopment.versionstring1 to 255 characters. Send
version,commitSha, or both.commitShastring7 to 64 hexadecimal characters: 40 for SHA-1 repositories, 64 for SHA-256. Unhandled stores it in lowercase. A shorter SHA is expanded when it matches exactly one commit already recorded on the project's deployments, and stored as sent when it matches none. When it matches several, the request fails with
422and the message tells you how many characters to send.statusstringdefaultsucceededstarted,succeeded,failedorcanceled.startedAtstringISO 8601 timestamp. Defaults to the time Unhandled receives the request.
finishedAtstringISO 8601 timestamp. Defaults to the receive time, or stays empty while
statusisstarted.externalIdstring1 to 255 characters. Your pipeline's ID for this rollout. Send it to update the same deployment later.
actorstringUp to 255 characters. The person or bot that triggered the deployment.
ciUrlstringUp to 2048 characters. A URL with a protocol, linked from the deployment page.
repositoryUrlstringUp to 2048 characters. A URL with a protocol. Unhandled uses it to link the commit.
metadataobjectAny JSON object up to 4 KB once serialized.
Request
curl -X POST "https://unhandled.net/api/v1/deployments" \
-H "content-type: application/json" \
-H "x-api-key: $UNHANDLED_API_KEY" \
-d '{
"service": "shop-api",
"environment": "production",
"version": "2026.10.21-1",
"commitSha": "3f9c2a7e5b1d4c8f0a6e2d9b7c4a1f3e5d8b0c2a",
"status": "started",
"externalId": "release-4812",
"actor": "release-bot",
"ciUrl": "https://ci.example.com/pipelines/4812",
"repositoryUrl": "https://github.com/example/shop",
"metadata": { "region": "eu-west", "ordersMigration": true }
}'const response = await fetch('https://unhandled.net/api/v1/deployments', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.UNHANDLED_API_KEY,
},
body: JSON.stringify({
service: 'shop-api',
environment: 'production',
version: '2026.10.21-1',
commitSha: '3f9c2a7e5b1d4c8f0a6e2d9b7c4a1f3e5d8b0c2a',
status: 'started',
externalId: 'release-4812',
actor: 'release-bot',
ciUrl: 'https://ci.example.com/pipelines/4812',
repositoryUrl: 'https://github.com/example/shop',
metadata: { region: 'eu-west', ordersMigration: true },
}),
})
if (!response.ok) throw new Error(`unhandled answered ${response.status}`)
const deployment = await response.json()import json
import os
import urllib.request
payload = {
"service": "shop-api",
"environment": "production",
"version": "2026.10.21-1",
"commitSha": "3f9c2a7e5b1d4c8f0a6e2d9b7c4a1f3e5d8b0c2a",
"status": "started",
"externalId": "release-4812",
"actor": "release-bot",
"ciUrl": "https://ci.example.com/pipelines/4812",
"repositoryUrl": "https://github.com/example/shop",
"metadata": {"region": "eu-west", "ordersMigration": True},
}
request = urllib.request.Request(
"https://unhandled.net/api/v1/deployments",
data=json.dumps(payload).encode(),
headers={
"content-type": "application/json",
"x-api-key": os.environ["UNHANDLED_API_KEY"],
},
method="POST",
)
with urllib.request.urlopen(request) as response:
deployment = json.load(response)Response
{
"id": "0b4f7d3e-2c1a-4e8b-9a6f-5d2c7e1b3a90",
"source": "api",
"status": "started",
"merged": false,
"url": "https://unhandled.example.com/o/acme/projects/shop/deployments/0b4f7d3e-2c1a-4e8b-9a6f-5d2c7e1b3a90"
}{
"message": "Deployment is already succeeded and cannot become started"
}{
"message": "Unknown environment \"prod\"",
"environments": ["production", "staging", "development"]
}url opens the deployment in the Unhandled app. merged is true when the declaration joined a deployment Unhandled had already detected from telemetry.
Report a rollout in two calls
A request without externalId always creates a new deployment. With one, Unhandled first looks for a deployment in the project with the same externalId and updates it. That lets a pipeline open the deployment before the rollout and close it afterwards.
Open the deployment
Send
"status": "started"with anexternalId, as in the request above. The deployment appears with no finish time.Close it with the outcome
Send the same
service,environmentandexternalIdwith the final status.curl -X POST "https://unhandled.net/api/v1/deployments" \ -H "content-type: application/json" \ -H "x-api-key: $UNHANDLED_API_KEY" \ -d '{ "service": "shop-api", "environment": "production", "version": "2026.10.21-1", "externalId": "release-4812", "status": "succeeded" }'Unhandled answers
200with the updated deployment.
On an update:
- Fields you leave out keep their previous values.
startedAtkeeps the value from the first request. A later value is ignored.finishedAtis set when the deployment reachessucceeded,failedorcanceled, unless you send it.
Allowed status changes
| From | To |
|---|---|
started | succeeded, failed or canceled |
| Any status | The same status again |
Any other change, such as succeeded back to started, returns 409. Reusing an externalId that belongs to a deployment of another service or environment also returns 409.
Merging with detected deployments
Unhandled also detects deployments when a service starts reporting a new version in its spans. A declaration joins a detected deployment of the same service and environment when both hold:
- The detected version was first seen within 60 minutes of
startedAt, before or after. - The
versionmatches, or thecommitShamatches.
A declaration that only carries a commit therefore links to the release whose spans reported that commit in vcs.ref.head.revision. A merged declaration returns 201 with "merged": true.
Status codes
| Code | When |
|---|---|
201 | Unhandled created a deployment, or merged the declaration into a detected one. |
200 | Unhandled updated the deployment with the same externalId. |
401 | The deploy key is missing, unknown or revoked, or the request sent a DSN key. |
403 | The project's organization is suspended. |
409 | The status change is not allowed, or the externalId belongs to another service or environment. |
422 | Validation failed, the environment is unknown, or a short commit SHA matches several commits. |
429 | The key sent more than 60 requests in the current minute. |
A validation failure returns an errors array whose entries carry a field and a message. An unknown environment returns a message and an environments array with the valid values. Every other error returns a message.
Rate limit
Each deploy key can send 60 requests per minute. The 61st request in that minute returns 429 with a retry-after header in seconds. The budget belongs to the key, so pipelines with separate keys never slow each other down.
Chart markers
Deployments with status failed or canceled stay in the history but get no marker on charts.
FAQ
Yes. Unhandled stores the deployment under the service name you sent, and the deployments list shows it under that name.
It matched more than one commit already recorded on the project's deployments. The error message names the length to send. Sending the full 40 or 64 characters avoids the lookup.
Look at source in the response or the deployments list. Requests with x-unhandled-source: cli are recorded as cli, every other request as api, and deployments Unhandled found in telemetry as detected.