Embedded SDK provider guide
This page is for the receiving application: the provider whose API or MCP tool performs the business write. The helpers live in @sectigo/axiom-sdk/provider (packages/sdk/src/provider.ts). They cover both ways a request can reach you:
| Path | What arrives | You call |
|---|---|---|
Through the hosted gateway (service mode proxy) | The request plus Axiom-Authorization, Idempotency-Key, Axiom-Action-Id | verifyDispatch |
Directly from the agent (service mode sdk) | The agent's own Authorization: DPoP … and DPoP headers, bound to your URL | authorize |
Either way you then commit once with executeOnce, sign the outcome with your own key and answer Axiom's reconciliation lookups. The Acme Billing sample (apps/sample-billing) does all of this and is the worked example throughout. Choosing between paths is covered in protect my service.
Developer preview
The SDK is a workspace package in this repository (TypeScript sources), not yet published to a registry.
Set up
import { signerFromJwk } from "@sectigo/axiom-sdk";
import { AxiomProvider } from "@sectigo/axiom-sdk/provider";
const provider = new AxiomProvider({
issuer: "https://api.sectigoaxiom.com", // iss of dispatch authorisations; keys from {issuer}/.well-known/jwks.json
audience: "https://billing.example.com", // your origin(s): the aud Axiom signs for
outcomeSigner: await signerFromJwk(JSON.parse(process.env.OUTCOME_PRIVATE_JWK!)),
apiKey: process.env.AXIOM_API_KEY, // only for authorize / reportOutcome (embedded mode)
});| Option | Meaning |
|---|---|
issuer | Trusted Axiom issuer. A dispatch token with another iss is rejected. |
audience | Your origin, or several. Compared by origin with the token's aud. |
apiUrl | API origin for authorize and reportOutcome; defaults to issuer. |
apiKey | A project API key (axk_…) with scope provider:authorize. |
jwksUrl / keys | Override the JWKS URL, or pin issuer keys and skip fetching. |
outcomeSigner | Your outcome key, any Signer (local JWK, KMS, HSM). Required for attestOutcome/executeOnce. |
clockSkewSeconds | Allowed skew for iat/exp, default 30. |
Register your outcome key
Axiom only accepts outcome attestations signed by keys registered on the service (outcome_keys). Publish the public half and register it:
app.get("/.well-known/axiom/outcome-keys.json", (c) => c.json(provider.outcomeKeys())); // { keys: [publicJwk] }PATCH /v1/services/svc_01kd0m5q1w2e3r4t5y6u7i8o9p
Content-Type: application/json
x-axiom-csrf: …
{ "outcome_keys": [{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…", "kid": "…", "alg": "ES256" }] }(project.write; from the console's service page or with a session.) Without a registered key, every attestation is rejected ("no provider outcome keys registered for this service") and dispatched actions end in outcome_unknown.
Restrict the upstream
Protected routes must be reachable only through an enforced path. The sample answers every protected route with 401 missing_authorization unless the request carries a valid Axiom-Authorization. If your service is in sdk mode, the equivalent is never committing without decision === "execute".
Proxy mode: verify the dispatch
The gateway forwards the agent's request with a compact JWS in Axiom-Authorization (typ: axiom-dispatch+jws, signed by Axiom's grant key, valid for 120 seconds) stating exactly what was authorised:
{
"v": "axiom.dispatch.v1",
"iss": "https://api.sectigoaxiom.com",
"aud": "https://billing.example.com",
"action_id": "act_01kd0m7s2v8x9yz3abcdefghjk",
"root_action_id": "act_01kd0m7s2v8x9yz3abcdefghjk",
"operation_key": "refund-417-7Qm2xK9pLr",
"operation": "billing.refund.create",
"tenant": "ten_01kd0m4z9x8c7v6b5n4m3l2k1j",
"agent": "agt_01kd0m6n3b4v5c6x7z8a9s0d1f",
"resource": { "type": "invoice", "id": "417" },
"request_digest": "sha-256:…",
"canonical": {
"v": "axiom.action.v1",
"tenant": "ten_…",
"agent": "agt_…",
"audience": "svc_…",
"operation": "billing.refund.create",
"resource": { "type": "invoice", "id": "417" },
"params": { "amount": "25000", "currency": "USD", "beneficiary": "acct_acme_ops" },
"not_after": "2026-11-15T18:00:00.000Z"
},
"claim_id": "clm_01kd0md…",
"iat": 1763143200,
"exp": 1763143320
}app.post("/v1/invoices/:id/refunds", async (c) => {
const ctx = await provider.verifyDispatch(c.req.raw.headers); // throws ProviderError (401/403)
const body = await c.req.json();
// The request you received must be the one that was approved.
provider.assertMatches(ctx, { resource: { type: "invoice", id: c.req.param("id") }, params: body });
// … executeOnce (below)
});verifyDispatch checks the signature against Axiom's grant keys (fetched from the JWKS and cached; revoked keys excluded), typ and v, iss, aud, exp and iat with skew, the presence of required claims, and that canonical reproduces request_digest. It also checks that any Idempotency-Key and Axiom-Action-Id headers agree with the signed claims, so a tampered header cannot redirect the commit to another key.
assertMatches compares the resource and every approved material field with what you received (decimal strings by value, "25000.00" equals "25000"; currencies case-insensitively) and throws 409 binding_mismatch listing the differing fields.
Take authority from ctx, never from request fields. The tenant is ctx.tenant; the resource must be ctx.resource. Your own rules (invoice status, refundable balance, business limits) still apply.
Commit exactly once
const { record, replayed } = await provider.executeOnce(store, ctx, async (ctx) => {
const inv = await invoices.get(ctx.tenant, ctx.resource.id);
if (!inv || inv.status !== "paid") return { state: "failed", detail: { reason: "invoice_not_paid" } };
const refundId = newRefundId();
return {
state: "succeeded",
target_ref: refundId,
detail: { refund_id: refundId, amount: "25000", currency: "USD" },
write: { refundId, invoiceId: inv.id, amountMinor: 2_500_000 }, // committed with the record
};
});
c.header("axiom-outcome", record.attestation); // proxy mode: the gateway reads this header
return c.json(record.detail, record.state === "succeeded" ? 201 : 422);executeOnce(store, ctx, fn):
- If the operation key already has a record, returns it (
replayed: true) without runningfn. A record bound to a different action or digest is409 idempotency_conflict, never silently reused. - Otherwise runs
fn, signs anaxiom.outcome.v1attestation for its result, and inserts the record atomically withfn'swrite. - If another writer won the race (
exists), returns the winner's record. If the write's own precondition failed (write_rejected, e.g. the balance changed), runsfnagain with fresh state, up tomaxAttempts(default 3), then409 idempotency_conflict.
A definitive failure is a recorded, signed outcome too (state: "failed", no write), so Axiom can close the action instead of reconciling it.
The store must be atomic
IdempotencyStore<W> has two methods, keyed by (tenant, operation_key):
interface IdempotencyStore<W> {
get(ref: { tenant: string; operation_key: string }): Promise<OperationRecord | undefined>;
insert(record: OperationRecord, write: W | undefined): Promise<"inserted" | "exists" | "write_rejected">;
}insert must commit the record and the business write in one transaction or atomic batch: both happen or neither does. That is what turns at-least-once delivery into at most one side effect. MemoryIdempotencyStore (a synchronous () => boolean write) is for tests only. The sample's D1BillingStore (apps/sample-billing/src/store.ts) is a production-shaped example: one D1 batch() inserts the operation record unless the key exists, inserts the refund only if that row is ours and the refundable balance still allows it (so parallel refunds under different keys cannot overdraw an invoice), deletes our record if the refund was not inserted, and reads back which commit owns the key.
Do not use an eventually consistent store (for example a KV cache) for this: two deliveries in different locations could both see "absent" and both commit.
Report the outcome
In proxy mode return the attestation in the axiom-outcome response header (and, for an MCP tool, optionally in _meta["com.sectigoaxiom/outcome"]). The gateway verifies it and records succeeded or failed as destination confirmed. Without a header, the gateway records only what it observed: 2xx as succeeded with reason_code: gateway_observed_success, 4xx other than 408/429 as failed with gateway_observed_failure, anything else as outcome_unknown. Only a valid attestation gives target_confirmed or target_failed.
In embedded mode report it yourself:
await provider.reportOutcome(ctx.action_id, record.attestation); // POST /v1/actions/{id}/outcomePOST /v1/actions/{id}/outcome (project API key with provider:authorize, body { "attestation": "…" }) accepts an attestation for an action that is executing or outcome_unknown, verifies it against the service's outcome keys and its binding to the action, operation key and digest, then records the outcome and issues the receipt. A replay after the outcome is recorded returns the prior result. A bad attestation is 403 forbidden.
attestOutcome(ctx, outcome) signs an attestation directly if you do not use executeOnce.
Answer reconciliation lookups
When a dispatch times out, Axiom asks you for the outcome of the operation key:
GET /.well-known/axiom/operations/refund-417-7Qm2xK9pLr HTTP/1.1
Host: billing.example.com
Axiom-Authorization: eyJ0eXAiOiJheGlvbS1kaXNwYXRjaCtqd3MiLC…app.get("/.well-known/axiom/operations/:key", (c) => provider.handleOperationLookup(store, c.req.param("key"), c.req.raw.headers));handleOperationLookup verifies the Axiom-Authorization (same checks as a dispatch, for this operation key), then answers 200 { state, attestation, target_ref } from your store, or 404 when the key never committed for that action. Axiom accepts only a valid attestation; anything else (including 404) leaves the action outcome_unknown and Axiom retries later. Upstream credentials configured on the service are sent with the lookup too.
Embedded mode: authorize
In sdk mode the agent calls you directly with its DPoP token, the proof bound to your URL. You forward the credential and the authoritative action fields to Axiom:
app.post("/v1/invoices/:id/refunds", async (c) => {
const body = await c.req.json();
const res = await provider.authorizeRequest(
c.req.raw,
{
operation_key: c.req.header("idempotency-key")!,
operation: "billing.refund.create",
service: "svc_01kd0m5q1w2e3r4t5y6u7i8o9p",
tenant: await tenantOf(c), // authoritative: from your own records
resource: { type: "invoice", id: c.req.param("id") },
params: { amount: body.amount, currency: body.currency, beneficiary: body.beneficiary },
},
"https://billing.example.com" + new URL(c.req.url).pathname,
); // external URL, if behind a proxy
switch (res.decision) {
case "execute": {
const { record } = await provider.executeOnce(store, res.context!, commitRefund);
await provider.reportOutcome(res.context!.action_id, record.attestation);
return c.json(record.detail, 201);
}
case "pending":
return c.json(res.status, 202); // nothing happened; the agent waits
case "in_progress":
return c.json(res.status, 202); // someone else holds the claim
case "completed":
return c.json(res.status, 200); // already done: the prior result
default:
return c.json(res.status, 403); // "stop": denied, expired, revoked, cancelled
}
});The raw call is POST /v1/authorize with Authorization: Bearer axk_…:
{
"agent": {
"authorization": "DPoP axa_Jd0…",
"dpop": "eyJ0eXAiOiJkcG9wK2p3dCIs…",
"htm": "POST",
"htu": "https://billing.example.com/v1/invoices/417/refunds"
},
"action": {
"operation_key": "refund-417-7Qm2xK9pLr",
"operation": "billing.refund.create",
"service": "svc_01kd0m5q1w2e3r4t5y6u7i8o9p",
"tenant": "ten_01kd0m4z9x8c7v6b5n4m3l2k1j",
"resource": { "type": "invoice", "id": "417" },
"params": { "amount": "25000", "currency": "USD", "beneficiary": "acct_acme_ops" }
}
}Axiom verifies the agent's token and proof against the htm/htu you received (if the service has an upstream_url, htu must be on its origin), so the agent never hands you anything you could replay against Axiom. It then submits the action as that agent (idempotent on the operation key) and, if the action is approved, resumes it and claims execution for you.
decision | Meaning | Do |
|---|---|---|
execute | This call holds the single execution claim. claim.authorization is a dispatch JWS; authorize() returns it verified as context. | Commit with executeOnce, then reportOutcome. |
pending | pending_approval. Nothing may happen. | Return 202 with status. |
in_progress | executing: a claim is already held. | Do not commit; return the status. |
completed | succeeded or failed. | Return the prior result. |
stop | Denied, expired, revoked, cancelled or outcome_unknown. | Do not commit. |
Tenant and resource must come from your own authoritative records, not from the agent's body.
Resuming an approved action
For an sdk service the agent resumes by repeating its original request to you with the same operation key. Your authorize call then finds the approved action, resumes it as the original agent and returns execute. Only this path can claim an sdk action: a resume through any Axiom path (REST POST /v1/actions/{id}/resume, the Gateway proxies, or the Axiom MCP server's axiom_action_resume) returns 409 invalid_state ("repeat the original request to the provider with the same operation key"), and the action stays approved. With the SDK on the agent side, pass run(..., { resume }) a function that re-sends the request (see the SDK reference).
Errors
ProviderError carries an HTTP status and a code, and toResponse() renders an RFC 9457 problem:
| Code | Status | When |
|---|---|---|
missing_authorization | 401 | No Axiom-Authorization (or no agent headers for authorizeRequest). |
invalid_authorization | 401 | Signature, type, issuer, audience, lifetime, claims or digest invalid. |
binding_mismatch | 403/409 | Headers disagree with the signed claims (403), or the request differs from the approved one (409). |
idempotency_conflict | 409 | The key already committed for a different action, or the business precondition kept changing. |
not_found | 404 | Reconciliation lookup for a key that never committed. |
upstream_error | 502 | authorize said execute without a claim. |
Errors from Axiom itself (authorize, reportOutcome) are AxiomProblem; see errors and reason codes.