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:
| Header | Value |
|---|---|
x-orboto-timestamp | Unix 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-id | Your 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-signature | sha256= 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=fa85fdc075c82a65baacdee1c46766c2d8e47a3bea05900690ab0065905ade6cUse 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.
| Status | Error | Meaning |
|---|---|---|
200 | - | Accepted: { received, inserted, updated, unmatchedUsers }. |
401 | webhook_headers_missing | A header is missing or malformed. A delivery signed over the body alone, the format before 0.230.0, ends here as well. |
401 | invalid_signature | The signature does not match the timestamp, delivery id and body. |
401 | webhook_timestamp_out_of_window | The signature is right, and the timestamp is more than 300 seconds off. |
409 | webhook_delivery_replayed | orboto already accepted this delivery id. Nothing was written. |
503 | webhook_not_configured | No 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:
/metricscounts every delivery inorboto_external_ai_spend_webhook_deliveries_total{outcome}, withoutcomeone ofaccepted,not_configured,headers_missing,invalid_signature,stale_timestampandreplayed.- The audit log records
external_ai_spend.webhook_ingestfor every accepted delivery, with its delivery id, andexternal_ai_spend.webhook_refusedfor 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.0 | From 0.230.0 | |
|---|---|---|
| Headers | x-orboto-signature | x-orboto-timestamp, x-orboto-delivery-id and x-orboto-signature |
| String that is signed | <body> | <timestamp>.<deliveryId>.<body> |
| Replay | accepted, rows inserted again | 409, nothing written |
| Old timestamps | accepted | refused 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=fa85fdc075c82a65baacdee1c46766c2d8e47a3bea05900690ab0065905ade6cTo move a sender:
- 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. - Generate a new delivery id for every delivery, and keep it when you retry.
- Add an
externalRefto every row that your system can send twice. - 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 anexternalRefmake that safe to repeat. - Check the result: a delivery answers
200, and/metricsshowsoutcome="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.
Lexware Office
Connect your workspace to Lexware Office with an API key: orboto checks it against your Lexware profile, keeps it in the credential vault, receives signed events about invoices, payments and contacts, and turns billable time into a draft invoice.
Calendar sync
See orboto schedules in your calendar - the personal feed, and two-way sync with Google, Microsoft 365, or any CalDAV server.