orbotodocs
Integrations

External AI spend webhook

Report AI spend from your own systems with a signed POST - headers, signature, retries, and how to move an existing sender to the signed format.

AI spend that happens outside orboto - a billing export, a gateway, a script around a provider's usage API - reaches orboto in four ways: you enter it by hand, import a CSV, let a provider connector poll it, or post it to the webhook described here. All of them land in Admin -> External AI spend (the Source column says which one) and feed the AI cost of the budget analytics.

The webhook is POST /ingest/external-ai-spend, served under /api like the rest of the REST API, for example https://acme.example.com/api/ingest/external-ai-spend. It takes up to 1,000 rows per delivery and 20 deliveries per minute. orboto ships no sender of its own: the sender is your script, and it signs every delivery.

Turning it on

Set the shared secret once. It needs admin:system:write, is stored encrypted and is masked on every read:

orboto patch /system/admin/config '{"key":"external_ai_spend_webhook_secret","value":"<random, at least 32 characters>"}'

Use a random value of at least 32 characters. Until a secret is set, the endpoint answers 503 (webhook_not_configured).

Signing a delivery

Added in v0.230.0 Every delivery carries three headers:

HeaderValue
x-orboto-timestampUnix seconds at which the sender signed the delivery. A timestamp more than 300 seconds (5 minutes) away from the instance clock, in either direction, is refused.
x-orboto-delivery-idYour own id, unique per delivery: 16 to 128 characters of A-Z a-z 0-9 _ . : -. An id orboto accepted in the last 24 hours is refused.
x-orboto-signaturesha256= followed by the hex HMAC-SHA256, keyed with your secret, over the string below. The sha256= prefix is optional.

The string to sign is

<timestamp>.<deliveryId>.<body>

where <body> is the request body byte for byte, exactly as you send it. Sign the bytes you put on the wire, not a copy you serialised a second time.

A worked example

The secret is spend-webhook-example-secret, the timestamp 1792600000, the delivery id dlv_2026-10-21_0001, and the body (on one line, as sent):

{"rows":[{"userEmail":"ana@example.com","provider":"anthropic","periodStart":"2026-10-01","periodEnd":"2026-10-31","amount":42.5,"currency":"EUR","externalRef":"inv-2026-10-ana"}]}

The string to sign is that line prefixed with 1792600000.dlv_2026-10-21_0001., and the three headers are

x-orboto-timestamp: 1792600000
x-orboto-delivery-id: dlv_2026-10-21_0001
x-orboto-signature: sha256=fa85fdc075c82a65baacdee1c46766c2d8e47a3bea05900690ab0065905ade6c

Use this to test a new sender: with the same inputs, your code has to produce the same signature.

A sender in shell:

ts=$(date +%s); id="dlv_$(uuidgen)"
sig=$(printf '%s.%s.%s' "$ts" "$id" "$body" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "https://acme.example.com/api/ingest/external-ai-spend" \
  -H 'content-type: application/json' \
  -H "x-orboto-timestamp: $ts" \
  -H "x-orboto-delivery-id: $id" \
  -H "x-orboto-signature: sha256=$sig" \
  --data-binary "$body"

And in Node.js:

import { createHmac, randomUUID } from 'node:crypto';

const body = JSON.stringify({ rows });
const timestamp = String(Math.floor(Date.now() / 1000));
const deliveryId = `dlv_${randomUUID()}`;
const signature = createHmac('sha256', process.env.ORBOTO_SPEND_SECRET)
  .update(`${timestamp}.${deliveryId}.${body}`)
  .digest('hex');

const res = await fetch('https://acme.example.com/api/ingest/external-ai-spend', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-orboto-timestamp': timestamp,
    'x-orboto-delivery-id': deliveryId,
    'x-orboto-signature': `sha256=${signature}`,
  },
  body,
});

What orboto answers

Added in v0.230.0 The new answers are webhook_headers_missing, webhook_timestamp_out_of_window and webhook_delivery_replayed.

StatusErrorMeaning
200-Accepted: { received, inserted, updated, unmatchedUsers }.
401webhook_headers_missingA header is missing or malformed. A delivery signed over the body alone, the format before 0.230.0, ends here as well.
401invalid_signatureThe signature does not match the timestamp, delivery id and body.
401webhook_timestamp_out_of_windowThe signature is right, and the timestamp is more than 300 seconds off.
409webhook_delivery_replayedorboto already accepted this delivery id. Nothing was written.
503webhook_not_configuredNo secret is set.

unmatchedUsers counts the rows whose userEmail or userId matches no orboto account; they are stored without a person and shown as Unattributed.

Retrying after a timeout

Added in v0.230.0 When a delivery times out and you do not know whether it arrived, send it again with the same delivery id, a fresh timestamp and a fresh signature over the new timestamp. If the first attempt went through, the retry answers 409, and you can treat the delivery as delivered. If orboto failed to write the rows, it releases the delivery id again, so the retry is accepted.

A delivery id is single-use for 24 hours. Never reuse one for a different payload, and do not generate the same id for two deliveries.

Rows and duplicates

Added in v0.230.0 Each row carries provider, periodStart and periodEnd (days, YYYY-MM-DD), amount and, optionally, currency (default EUR), userEmail or userId, convertedAmount, tokensIn, tokensOut, externalRef and notes.

Give every row an externalRef, your own invoice or usage id. A row with one is updated, not duplicated, when you send it again (the key is provider plus externalRef). A row without one is keyed by its delivery and its position in it, so a re-sent delivery with the same id updates its rows instead of adding them again. Two deliveries with different ids are two sets of spend.

Watching deliveries

Added in v0.230.0 Two places show what happened to a delivery:

  • /metrics counts every delivery in orboto_external_ai_spend_webhook_deliveries_total{outcome}, with outcome one of accepted, not_configured, headers_missing, invalid_signature, stale_timestamp and replayed.
  • The audit log records external_ai_spend.webhook_ingest for every accepted delivery, with its delivery id, and external_ai_spend.webhook_refused for a stale or replayed delivery whose signature was valid. A delivery with a wrong signature is counted and logged but not audited, so a flood of unauthenticated requests cannot fill the audit log.

Moving an existing sender to the signed format

Added in v0.230.0 This is a breaking change for every sender.

Senders must be updated

Before 0.230.0 the signature covered the body only, and a replayed delivery inserted its rows a second time. From 0.230.0 on, a delivery signed over the body alone is refused with 401 webhook_headers_missing. Every sender needs the two new headers and the new string to sign.

What changes in a sender:

Before 0.230.0From 0.230.0
Headersx-orboto-signaturex-orboto-timestamp, x-orboto-delivery-id and x-orboto-signature
String that is signed<body><timestamp>.<deliveryId>.<body>
Replayaccepted, rows inserted again409, nothing written
Old timestampsacceptedrefused after 300 seconds

With the example above, the same body, secret and sha256= format give:

before: x-orboto-signature: sha256=c147d0b57d948bc810c3f98fd10ef93dcf5d1823993c9d19837207feb1090ee4
after:  x-orboto-timestamp: 1792600000
        x-orboto-delivery-id: dlv_2026-10-21_0001
        x-orboto-signature: sha256=fa85fdc075c82a65baacdee1c46766c2d8e47a3bea05900690ab0065905ade6c

To move a sender:

  1. Change the signing code: build <timestamp>.<deliveryId>.<body>, sign that, and send the timestamp and the delivery id as headers. Check your code against the worked example above.
  2. Generate a new delivery id for every delivery, and keep it when you retry.
  3. Add an externalRef to every row that your system can send twice.
  4. Switch the sender in the same window as the orboto update. A version before 0.230.0 verifies the body alone and refuses the new format with 401 invalid_signature, and 0.230.0 refuses the old one, so deliveries made between the two steps fail. Queue them and send them after the switch; rows with an externalRef make that safe to repeat.
  5. Check the result: a delivery answers 200, and /metrics shows outcome="accepted".

Troubleshooting

401 webhook_headers_missing. One of the three headers is missing or malformed (a timestamp that is not whole seconds, or a delivery id outside 16 to 128 characters of A-Z a-z 0-9 _ . : -), or the sender still signs the body alone. Move it to the signed format above.

401 invalid_signature. The signature is not the HMAC of <timestamp>.<deliveryId>.<body> with the current secret. The usual causes are signing a different serialisation of the body than the one sent, a secret that was changed on one side, or the old body-only string.

401 webhook_timestamp_out_of_window. The signature is right, but the timestamp is more than 5 minutes from the instance clock. Check the clock of the machine that signs, and sign again at the moment of sending, not when the payload was built.

409 webhook_delivery_replayed. orboto accepted a delivery with this id in the last 24 hours and wrote its rows, so they are in Admin -> External AI spend. After a retry that is the expected answer. If it answers 409 for a payload you have not sent before, your sender repeats delivery ids: use a random id for every new delivery.

On this page