Core concepts

Emitters

Settled usage, delivered where you bill from.

An emitter is a destination for settled usage: an https endpoint you own, a Metronome ingest, or an Orb ingest. Every call that settles is delivered to each enabled emitter on the application, after the money has been recorded, so a destination that is down cannot change what a call cost or make the caller wait for it.

cli
seams emitters add webhook --url https://example.com/seams/usage

# emitter         emt_01K2VY9WQ4X00000000EMT
# kind            webhook
# name            https://example.com/seams/usage
# enabled         true
# destination     https://example.com/seams/usage
# events          usage.recorded
# customer id     end_user_id
# credential      …9f2c
# signing secret  whsec_…

The signing secret is in that response and nowhere else. Store it before you run the next command.

Managing them

CommandWhat it does
seams emitters listEvery destination, where it sends and whether it is on
seams emitters show <id>One destination, its events and the last four of its credential
seams emitters add webhook --url <https url>Post batches to an endpoint you own
seams emitters add metronome --api-token <token> --event-type <type>Ingest into Metronome
seams emitters add orb --api-key <key> --event-name <name>Ingest into Orb
seams emitters update <id> --disableStop delivering, keep the configuration
seams emitters update <id> --api-token <token>Rotate the stored credential
seams emitters rm <id>Stop delivering there, and forget the credential
seams emitters test <id>Deliver one sample now

The same six operations are GET, POST, PATCH and DELETE on /v1/emitters, and the console has a screen for them. Add takes --name to label a destination and --disabled to configure one without turning it on.

One event

usage.recorded fires when a call settles and writes a usage row. It is the only event an emitter carries; any other name is refused when you configure it, rather than accepted and never delivered.

cli
seams emitters add webhook --url https://example.com/usage --events usage.settled

# error
#   code     unknown_event
#   message  no emitter event 'usage.settled': usage.recorded
#   fix      seams emitters add webhook --events usage.recorded

What a webhook receives

A batch is posted as JSON with two headers: seams-signature, and seams-idempotency-key naming the batch. The body carries the settled amounts as decimal strings of micro-dollars, so no JSON number can round a balance.

json
{
  "id": "usgb_4b1f7c0a92e5f3d18a6c07b2e9d45183",
  "type": "usage.recorded",
  "events": [
    {
      "idempotencyKey": "usg_01JB2X8Q7M4K00000000SET",
      "usageRecordId": "usg_01JB2X8Q7M4K00000000SET",
      "organizationId": "org_01JQZ8N4KP0000000000ACME",
      "endUserId": "eu_01JQZ8N4KP00000000000ANNA",
      "apiKeyId": "key_01JQZ8N4KP000000000000KEY",
      "requestId": "req_01JQZ8N4KP000000000000REQ",
      "provider": "openai",
      "model": "openai/gpt-4o-mini",
      "streamed": false,
      "tokens": {
        "inputTokens": 1204,
        "outputTokens": 318,
        "cachedInputTokens": 0,
        "reasoningTokens": 0,
        "tokenCountSource": "provider_reported"
      },
      "providerCostMicros": "3044",
      "billedAmountMicros": "3652"
    }
  ]
}

There is no prompt and no completion here, and there is nowhere for one to appear: the usage record has no body column to read from.

Verify before you parse

The signature header carries the second it was signed and an HMAC-SHA256 over t, a full stop, and the raw body, keyed with your signing secret. Sign the same string yourself and compare in constant time. A body you have parsed is a body you have already trusted.

http
seams-signature: t=1756060800,v1=5257a869e7ecebeda32affa62cdca3fa…
typescript
import { createHmac, timingSafeEqual } from "node:crypto"

const verify = (rawBody: string, header: string, secret: string): boolean => {
  const parts = new URLSearchParams(header.replace(/,/g, "&"))
  const t = parts.get("t") ?? ""
  const v1 = parts.get("v1") ?? ""
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
  return v1.length === expected.length &&
    timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}

Delivery

Delivery is at-least-once, so your handler has to be idempotent. Two keys make that cheap: the POST carries seams-idempotency-key for the whole batch, and every event inside carries an idempotencyKey equal to its usage record id. Store either one and a repeat is a no-op.

A batch that is not accepted is retried twice more, after 200ms and then 400ms. 408, 425, 429, 500, 502, 503 and 504 are treated as worth retrying; any other status is taken as an answer. The body is identical on every attempt, though each attempt is signed afresh, so t and v1 differ.

There is no queue behind those three attempts. A batch that fails all three is not delivered later, and the call it describes is still in seams usage, seams requests and GET /v1/usage. so reconcile from there, and answer 2xx on receipt rather than after your own downstream work.

Where a webhook may point

A webhook url must be https, and its host must resolve to a public address. Private ranges, loopback and link-local are refused, so a url cannot be used to reach inside our network. The address is checked on every delivery, not once when you save it, and a url that resolves somewhere private is a configuration fault rather than something to retry.

Metronome

Settled usage arrives as ingest events. transaction_id is the usage record id, which is what stops a repeat being rated twice, and event_type is the one you named when you added the emitter.

cli
seams emitters add metronome \
  --api-token mtr_… \
  --event-type app.inference \
  --customer-id organization_id
json
[
  {
    "transaction_id": "usg_01JB2X8Q7M4K00000000SET",
    "customer_id": "org_01JQZ8N4KP0000000000ACME",
    "event_type": "app.inference",
    "timestamp": "2026-09-07T09:20:00.000Z",
    "properties": {
      "provider": "openai",
      "model": "openai/gpt-4o-mini",
      "streamed": false,
      "input_tokens": 1204,
      "output_tokens": 318,
      "cached_input_tokens": 0,
      "reasoning_tokens": 0,
      "provider_cost_micros": "3044",
      "billed_amount_micros": "3652",
      "request_id": "req_01JQZ8N4KP000000000000REQ",
      "api_key_id": "key_01JQZ8N4KP000000000000KEY"
    }
  }
]

Orb

The same events, and the same properties, in Orb's shape. idempotency_key is again the usage record id, and event_name is the one you named.

cli
seams emitters add orb --api-key orb_… --event-name inference
json
{
  "events": [
    {
      "idempotency_key": "usg_01JB2X8Q7M4K00000000SET",
      "external_customer_id": "eu_01JQZ8N4KP00000000000ANNA",
      "event_name": "inference",
      "timestamp": "2026-09-07T09:20:00.000Z",
      "properties": { "provider": "openai", "billed_amount_micros": "3652" }
    }
  ]
}

The properties both vendors receive

PropertyWhat it holds
providerWhich vendor served the call
modelThe model that answered, not the name that was asked for
streamedWhether the call was streamed
input_tokens, output_tokensCounted for the attempt that settled
cached_input_tokens, reasoning_tokensZero when the provider reports neither
provider_cost_microsWhat the call cost to serve, as a decimal string
billed_amount_microsWhat it billed, as a decimal string
request_id, api_key_idPresent when the call carried them

Which customer the usage is filed under

--customer-id end_user_id is the default and sends the end user the key belongs to, falling back to the organization when a call has no end user. --customer-id organization_id files everything under the customer instead, which is what you want when you bill your account rather than their customers.

The id on the wire is ours. eu_…, not the external id you minted the key with. seams users show alice prints both, so map them once when you create the customer in Metronome or Orb.

Credentials

Every credential is sealed on the row with an envelope key, and only the delivery path opens it. Nothing else can read one back: list and show print the last four characters and the destination, never the secret.

A Metronome token or an Orb key is replaced in place with seams emitters update <id> --api-token … or --api-key …. A webhook's signing secret is not: it is issued once when the emitter is added, so rotating it means removing the emitter and adding it again.

Check it before real usage depends on it

test delivers one sample usage.recorded now, with an id of its own, so a destination that cannot be reached says so here rather than at your first settled call. The sample belongs to no end user, so it is filed under your organization whichever customer id an emitter is set to.

cli
seams emitters test emt_01K2VY9WQ4X00000000EMT

# emitter          emt_01K2VY9WQ4X00000000EMT
# kind             webhook
# destination      https://example.com/seams/usage
# event            usage.recorded
# idempotency key  usg_01K2VY9WQ4X0000SAMPLE
#
# sample
#   usage record id       usg_01K2VY9WQ4X0000SAMPLE
#   customer id           org_01JQZ8N4KP0000000000ACME
#   request id            req_01K2VY9WQ4X0000SAMPLE
#   provider              sample
#   model                 sample
#   streamed              false
#   input tokens          1820
#   output tokens         310
#   billed amount micros  4200
#
# delivered   true
# reason
# latency ms  142
# sent at     2026-09-07T09:20:00.000Z