Record outcomes API
A run finishing is not the same as the work holding up. The ticket gets reopened, the order gets refunded, the pull request gets reverted. This API takes that later fact, keyed by the record rather than by the run, and labels every run that acted on the record.
For it to find the runs, they must carry a record key such as
("zendesk_ticket", "4812"). You send the event; Morse does the matching.
How it works
Auth
Send an API key with write access as a bearer token:
Authorization: Bearer mhq_…Environment keys and full-access personal keys work. A read-only key is refused with 403. If the
key is scoped to one environment, the event is stored with that environment and only labels runs
from it: a staging key never labels production runs.
Send an event
POST https://api.morsehq.dev/api/v1/outcomes/by-keySend one event as the body, or up to 100 in a list under events:
{
"key_type": "zendesk_ticket",
"key_value": "4812",
"success": false,
"outcome": "reopened",
"occurred_at": "2026-10-12T10:15:00Z",
"event_id": "ticket-4812-reopened-2026-10-12T10:15:00Z"
}{
"events": [
{ "key_type": "github_pr", "key_value": 1123, "success": true, "outcome": "merged" },
{ "key_type": "github_pr", "key_value": 1124, "success": false, "outcome": "closed_unmerged" }
],
"dry_run": false
}| Field | Type | Notes |
|---|---|---|
key_type | string | Required. Lower-cased. Must match ^[a-z0-9][a-z0-9_.-]{0,63}$. |
key_value | string | Required. 1–256 characters after trimming. An unquoted integer is accepted. |
success | boolean | Required. Did the record end up in a good state? |
outcome | string | Optional label, up to 255 characters, such as reopened, refunded, merged. |
occurred_at | datetime | Optional. When the event happened (ISO 8601). Defaults to when Morse receives it. |
event_id | string | Optional, up to 128 characters. Makes the call safe to retry; see Retries. |
state | object | Optional. A typed record state (resolved or reopened) that outcome rules read. |
dry_run | boolean | Optional, top level. Computes the result without labeling any run or storing the event. |
The state field is optional and additive; an event without it behaves as described on this
page. Its fields are in Outcome rules.
For pull requests connected through the GitHub connector, the key value
is owner/repo#number, not the bare number used in the examples here.
The response
A single event returns one result. A list returns {"results": [...]}, one per event and in the
same order.
{
"event_id": "ticket-4812-reopened-2026-10-12T10:15:00Z",
"matched": 3,
"applied": 2,
"kept_higher_confidence": 1,
"kept_newer": 0,
"pending": false,
"truncated": false,
"key_type_seen": true,
"trace_ids": ["…", "…"]
}| Field | Meaning |
|---|---|
matched | Runs that carry the key, started in time, in your environment scope. |
applied | Runs whose label was written or changed by this event. |
kept_higher_confidence | Runs left alone because they already hold a label you set yourself or confirmed. |
kept_newer | Runs left alone because they already hold a record label from a later event. |
pending | true when nothing matched yet. The event is kept and applied if a matching run arrives; see below. |
truncated | true when more than 1,000 runs matched. The 1,000 most recent were labeled. |
key_type_seen | false when your organization has never sent a run with this key_type. |
trace_ids | Up to 100 of the labeled runs. |
pending: true with key_type_seen: false is almost always a typo, such as zendesk_tickets
for zendesk_ticket. You see it on the first call instead of after a week of silence.
Rules
Provenance. A label written by this API has provenance record: the business outcome of the
record the run acted on. It ranks between a per-run webhook outcome and an inferred one. It never
overwrites a label set in code with set_outcome(), one a person set in the dashboard, a
classifier-confirmed one, or a per-run webhook one. Those runs are counted in
kept_higher_confidence.
Time bound. An event only labels runs that started at or before its occurred_at, plus 5
minutes of clock-skew allowance. A “reopened” event does not label the runs that handled the
reopen.
Ordering. A record label replaces an earlier record label only if the new event’s occurred_at
is not older. If “reopened” (10:15) is applied and a “solved” event for 09:00 arrives late, the run
keeps “reopened” and the late event is counted in kept_newer.
Runs that arrive after the event. Every event from the last 7 days is applied to runs that gain
the key afterwards, under the same rules. That covers runs still open when the event arrived. After
7 days an event that matched nothing is expired.
Environment scope. An environment-scoped key only matches runs from that environment.
Fan-out. At most 1,000 runs per event, most recent first. Beyond that the response says
truncated: true.
Retries
event_id is the idempotency key. If you send the same event_id again, Morse returns the first
result and writes nothing, so a webhook that retries does not double-apply. Build it from facts
that do not change between retries: the record, the transition and its time.
Without an event_id, every call is a new event.
Errors
Validation failures return 422 with a field-level message. 401 means no valid key, 403 a key
without write access. The endpoint shares your key’s write rate limit; over it you get 429 with
Retry-After. Another organization’s keys are never matched.
Check what happened: GET /api/v1/outcomes/events
Lists your recent record-outcome events, newest first, with their status and counts.
curl -H "Authorization: Bearer $MORSE_API_KEY" \
"https://api.morsehq.dev/api/v1/outcomes/events?status=pending&limit=20"| Parameter | Notes |
|---|---|
key_type | Only events for this key type. |
status | applied, pending, expired or no_effect. |
limit | 1–200 (default 50). |
before | A datetime; return events received before it. Use next_before to page. |
Each event has id, event_id, key_type, key_value, success, outcome, occurred_at,
received_at, environment_id, status, applied and matched. The response also carries
next_before, which is null on the last page.
| Status | Meaning |
|---|---|
applied | At least one run was labeled. |
pending | Nothing carries the key yet. Still inside the 7-day window. |
expired | Nothing carried the key within 7 days. |
no_effect | Runs matched, but none changed: all held a higher-confidence or newer label. |
Counts are derived from the label history, not from a counter that can drift. The same numbers appear per agent under Capture health on the agent page.
How to use it
Requires SDK 0.7.0 (not yet released; 0.6.0 does not have this). link_record() in the examples below is a 0.7.0 SDK call. On 0.6.0, attach the record key over REST or OTLP (see Record keys and versions).
Morse does not poll your tools. You send your events: from a CI step, a webhook, a script. Two common ones follow. Use the same pattern for refunds, chargebacks, reverts or any other record transition you can observe.
Pull requests: a GitHub Actions step
Your agent opens or touches a pull request and calls
link_record("github_pr", pr.number). When the pull request closes, send whether it merged:
name: Send PR outcome to Morse
on:
pull_request:
types: [closed]
jobs:
send-outcome:
runs-on: ubuntu-latest
steps:
- name: Send the outcome
env:
MORSE_API_KEY: ${{ secrets.MORSE_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
MERGED: ${{ github.event.pull_request.merged }}
CLOSED_AT: ${{ github.event.pull_request.closed_at }}
run: |
jq -n \
--arg value "$PR_NUMBER" \
--argjson merged "$MERGED" \
--arg at "$CLOSED_AT" \
'{
key_type: "github_pr",
key_value: $value,
success: $merged,
outcome: (if $merged then "merged" else "closed_unmerged" end),
occurred_at: $at,
event_id: ("github-pr-" + $value + "-" + $at)
}' |
curl --fail-with-body -sS -X POST https://api.morsehq.dev/api/v1/outcomes/by-key \
-H "Authorization: Bearer $MORSE_API_KEY" \
-H "Content-Type: application/json" \
-d @-merged is true only for a merged pull request, so a closed-without-merging pull request is sent
as success: false. A pull request that is reopened and closed again has a new closed_at, so it
gets a new event_id. If the pull request is later reverted, send a second event from whatever
observes the revert.
Support tickets: a helpdesk trigger
Most helpdesks can call a webhook when a ticket changes status. Configure a trigger on the transitions you care about (reopened, solved, escalated) and have it send a body like this, with your tool’s template variables in place of the placeholders:
{
"key_type": "helpdesk_ticket",
"key_value": "{{ticket.id}}",
"success": false,
"outcome": "reopened",
"occurred_at": "{{ticket.updated_at}}",
"event_id": "ticket-{{ticket.id}}-reopened-{{ticket.updated_at}}"
}The key type is yours to choose; use the same string in link_record("helpdesk_ticket", ticket.id).
If your tool’s webhook can set an Authorization header, point it straight at the endpoint.
Otherwise send it through a small relay (a serverless function or an automation step) that adds the
header. Template variables that render as bare numbers are fine, since integers are accepted for
key_value.
Send one trigger per transition. A “solved” trigger with success: true and a “reopened” trigger
with success: false are two events about the same ticket; the later occurred_at wins.
Try it without writing anything
curl -X POST https://api.morsehq.dev/api/v1/outcomes/by-key \
-H "Authorization: Bearer $MORSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"key_type":"zendesk_ticket","key_value":"4812","success":false,"outcome":"reopened","dry_run":true}'dry_run returns the same counts, so you can check matched, key_type_seen and
kept_higher_confidence against real data before wiring up the sender.
Related
- Record keys and versions: put the key on the run.
- Query API: read traces and outcomes back out.
- OTLP: declare a per-run outcome with
morse.outcome.success.