Documentation
Everything you need to integrate Paitify into your AI agent stack.
Which one do I need? Building your own backend or app that calls Paitify programmatically? Use the API (API key, server-to-server). Connecting an AI agent client like Claude to Paitify so it can act for a human? Use MCP (OAuth sign-in, no API key).
Start here
One-time setup — the same for both MCP and the API.
- Sign up — your company account is created automatically on first sign-in.
- Create at least one agent (Agents in the dashboard).
- Create an active spending policy for it (Policies in the dashboard) — a spend request is declined if no active policy applies to the agent.
With that in place, pick your path: MCP to connect an AI agent client, or API to integrate your own backend.
MCP
OAuthConnect an AI agent client — Claude, or any MCP-compatible client — to Paitify. No API key to manage.
Paitify's MCP server lets an AI agent authorize and manage payments through your Paitify policies via a one-time login instead of a static API key — scoped to spend actions only, never policy, billing, or key management.
Auth model:OAuth. You sign in once, in your browser, with your existing Paitify account. The client (Claude) holds the resulting token — there is nothing to copy, paste, or rotate yourself. That token is scoped to MCP only — it can't be used to call the dashboard or the API, even if it leaked.
Quickstart — connect Claude in 4 steps
- In Claude, go to Settings → Connectors → Add custom connector, and paste the server URL:
https://connect.paitify.io/mcp. - Claude opens your browser to sign in. If you're not already signed in to Paitify, log in with your usual account — same login as the dashboard.
- Once signed in, the connection completes automatically and your browser returns to Claude — there's no separate "approve" screen today. What you're granting: Claude can act using your Paitify account's own authority — it can never do more than you could already do on the dashboard.
- You'll know it worked when Paitify's tools (
authorize_spend,get_remaining_budget, etc.) show up in Claude's tool list for that chat.
Available tools
Five tools, matching the machine surface exposed on the API — no policy-management, key-management, or reversal tools are on MCP; those stay dashboard/human-only.
| Tool | Does | What to say to your agent |
|---|---|---|
authorize_spendmutating | Request pre-authorization for a spend transaction. Returns AUTHORIZED or CAPTURED (depending on your company's capture mode), REQUIRES_APPROVAL, or DECLINED_BY_POLICY. | “Authorize a $249.99 payment to AWS for cloud hosting.” |
capture_spendmutating | Book (capture) a previously AUTHORIZED spend after the charge actually succeeded. Only applies in EXPLICIT capture mode. | “The AWS charge went through — capture that authorization.” |
cancel_spendmutating | Cancel a pending (not yet captured) authorization when the payment fails or is abandoned. Restores the reserved budget. | “Cancel that authorization — the payment didn't go through.” |
get_remaining_budgetread-only | Check how much budget remains for an agent across all configured spend-limit periods (daily/weekly/monthly). | “How much budget do I have left this month?” |
get_authorization_statusread-only | Check the live state of a specific authorization — the check to run before relying on an authorization's token. | “Is that authorization still valid before I charge it?” |
authorize_spend, capture_spend, cancel_spend) are flagged to Claude as destructive — Claude will typically confirm with you before running one. This is a client-side hint, not the security boundary; the server enforces role checks independently on every call.Trust model
- The token from authorize_spend is a bearer credential, not a permanent record.It's valid for up to 15 minutes. Signature and expiry alone are not sufficient proof a spend is still authorized —
cancel_spend, a dashboard reversal, or a human denial can all invalidate the authorization without revoking the token itself. Anyone relying on the token (an agent, a payment processor) should callget_authorization_statusimmediately before honoring it. - A decline is data, not an error. A policy rejection comes back as a normal tool result with
state: "DECLINED_BY_POLICY"and a shortreasonCode— never an exception. Agents should branch onstate, not on whether the call "succeeded." - Over-threshold requests wait for a human.If a transaction exceeds the policy's approval threshold,
authorize_spendreturnsstate: "REQUIRES_APPROVAL",requiresHumanApproval: true, and an emptyjwtToken— this is not authorized yet and not declined. A dashboard reviewer must approve or deny it; the agent should pollget_authorization_statusor wait for the corresponding webhook, then re-check state before acting.
Troubleshooting
Can't connect / stuck on sign-in
You don't need to be signed in beforehand — Claude opens a browser window and prompts you to log in as part of connecting. Just finish that sign-in (same Paitify account you use for the dashboard) in that same window, then retry the connection from Claude if it didn't complete.
Redirected back to sign-in repeatedly (login loop)
This usually means the browser session didn't carry through the redirect (e.g. a private/incognito window, or cookies blocked). Retry in a normal browser window signed in to Paitify.
API
X-API-KeyIntegrate Paitify into your own backend or application — server-to-server, machine-to-machine.
Quickstart
- Generate an API key from Settings → API.
- Choose a capture mode from Settings → General (default: AUTO).
- Call
POST /v1/integration/authorizebefore every spend event. If denied, stop. - AUTO: spend commits immediately on authorize. If the payment fails, call
POST /v1/integration/authorize/{id}/reverseto restore the budget. - EXPLICIT: spend is reserved on authorize. Call
POST /v1/integration/authorize/{id}/captureon success, or/cancelto release the reservation.
# AUTO mode (default) — happy path is one call
# 1. Request authorization — spend commits immediately if APPROVED
curl -X POST https://api.paitify.io/v1/integration/authorize \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agentId": "3f4a1b2c-...",
"amount": 249.99,
"currency": "USD",
"merchantName": "AWS",
"mccCode": "7372"
}'
# → { "state": "CAPTURED", "requiresHumanApproval": false, "authorizationId": "9e1d...", ... }
# 2. Only call reverse if the downstream payment fails
curl -X POST https://api.paitify.io/v1/integration/authorize/9e1d.../reverse \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "agentId": "3f4a1b2c-...", "reason": "Payment gateway declined." }'Authentication
Every request (except the JWKS endpoint) must include an X-API-Key header. Generate keys from Settings → API in the dashboard. Keys are stored as bcrypt hashes server-side — only the prefix is retained for display.
| Field | Type | Required | Description |
|---|---|---|---|
| X-API-Key | string | Yes | Your Paitify API key (e.g. sk_xxxxxxxxxxxx). Passed in every request header. |
| Content-Type | string | Yes | Must be application/json for requests with a body. |
curl -H "X-API-Key: sk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
https://api.paitify.io/v1/integration/agents/3f4a1b2c-.../budgetCapture Mode
Capture mode controls when a spend amount is committed to your agent's budget counters. Set it per company in Settings → General.
Spend commits immediately when the authorization is approved. Happy path = one API call. If the downstream payment fails, call /reverse to restore the budget.
Spend is reserved on authorize (state = AUTHORIZED). Call /capture after the payment succeeds, or /cancel to release the reservation. Authorization expires after 15 minutes if not actioned.
Authorization Lifecycle
The lifecycle differs by capture mode. In both modes a policy rejection sets state to DECLINED_BY_POLICY immediately — no further calls needed.
AUTO mode
Standard path
Human approval path
APPROVALAwaiting reviewer
EXPLICIT mode
Standard path
Human approval path
APPROVAL
Trust Model
Three rules govern how a spend authorization should be treated once it leaves the API:
- The signed
jwtTokenis the bearer authority for a payment — anyone holding it can prove the spend was authorized. stateis the truthful status of the authorization — it is the sole source of truth, never a separately-tracked decision field that could disagree with it.- A relying party holding a token for any non-trivial time should confirm current state via
GET /v1/integration/authorizations/{authorizationId}before honoring it, because a token remains cryptographically valid until itsexpclaim even if the authorization is later cancelled or reversed.
Why agent-facing responses are terse. The direct POST /v1/integration/authorize response (and its capture/cancel/reverse counterparts, and an X-API-Key-authenticated call to GET /v1/integration/authorizations/{id}) deliberately omits the full rule breakdown and configured limits that the dashboard shows. A compromised or prompt-injected agent that can call the API is given only a short reasonCode on decline — never the full policy configuration — so it cannot use these endpoints as an oracle to probe your spend limits, allowlists, or thresholds. The full detail (denial reason text, every rule evaluated, limit vs. actual values, and which rule was decisive) is only ever shown in the dashboard — it is not part of this API and is never returned in an authorize/capture/cancel/reverse response or a GET /v1/integration/authorizations/{id} response, for any X-API-Key caller. (GET /v1/integration/agents/{agentId}/budget, below, is a deliberate exception — it returns an agent's own configured spend limits for self-checking, but never the full rule-evaluation detail described here.) The full audit trail is likewise a dashboard-only, human-authenticated feature — there is no audit API endpoint an integration can call.
The state conditional is a one-time signal, not a poll result. Branching on state from the direct POST /v1/integration/authorize response is only valid once, right when you receive that response — act on it immediately (e.g. CAPTUREDthere means "charge now, it was auto-captured"). Do not reuse that same conditional when later polling GET /v1/integration/authorizations/{id}: on a poll, CAPTUREDalways means "already handled elsewhere — do not charge again." Never re-derive a charge decision from a poll response.
The full AuthorizationDetail shape — shown only in the dashboard, to a signed-in human session — also carries one durable provenance field not present on the terse, X-API-Key-visible shape:
| Field | Type | Nullable | Description |
|---|---|---|---|
| requiredHumanApproval | boolean | No | Durable: true iff this authorization ever passed through REQUIRES_APPROVAL, and stays true after capture. Not the same as the terse response's requiresHumanApproval, which is only true while state IS CURRENTLY REQUIRES_APPROVAL. |
On decline, the terse (X-API-Key-visible) shape returns one of these reasonCode values. This is the complete list — nothing else is ever emitted:
| reasonCode | Meaning |
|---|---|
| AGENT_NOT_FOUND | agentId does not exist for your company (or belongs to another company). Returned directly — never persisted as an authorization row. |
| AGENT_DEACTIVATED | The requesting agent exists but is deactivated — no policy rules were evaluated. |
| NO_ACTIVE_POLICY | No active policy is assigned to the agent (and none applies company-wide) — no policy rules were evaluated. |
| TRANSACTION_LIMIT | The amount exceeds the policy's per-transaction limit (in that limit's own currency). |
| CURRENCY_BLOCKED | The request's currency is on the policy's blocked-currency list. |
| CURRENCY_NOT_ALLOWED | The policy has a currency allowlist and the request's currency is not on it. |
| CURRENCY_NOT_COVERED | The policy has at least one limit configured (transaction, spend, or approval-threshold) but none of them are in the request's currency — deny-by-default so an unenumerated currency can't bypass every limit on the policy. |
| MCC_NOT_PROVIDED | The policy has an MCC allow/block list configured but the request omitted mccCode. |
| MCC_BLOCKED | The transaction's MCC code is on the policy's blocked list. |
| MCC_NOT_ALLOWED | The policy has an MCC allowlist and the transaction's MCC code is not on it. |
| MERCHANT_NOT_PROVIDED | The policy has a merchant allow/block list configured but the request omitted both merchantId and merchantName. |
| MERCHANT_BLOCKED | The request's merchant is on the policy's blocked list. |
| MERCHANT_NOT_ALLOWED | The policy has a merchant allowlist and the request's merchant is not on it. |
| SPEND_LIMIT_DAILY | The transaction would exceed the policy's daily spend limit. |
| SPEND_LIMIT_WEEKLY | The transaction would exceed the policy's weekly spend limit. |
| SPEND_LIMIT_MONTHLY | The transaction would exceed the policy's monthly spend limit. |
| VELOCITY | The agent exceeded the policy's maximum transactions per time window. |
| BUSINESS_HOURS | The request arrived outside the policy's configured business hours/days. |
| DECLINED | Generic fallback used for human-approval-workflow denials (state DECLINED_BY_HUMAN) that don't map to a single policy rule. |
reasonCode you will see in a direct 200 response. Exceeding it returns 402 Payment Required instead — see Error Reference below. (Internally the attempt is still recorded with a MONTHLY_QUOTA_EXCEEDED reason, visible to a dashboard session, but the API caller who made that specific call never receives a 200 body to read it from.)API Reference
/v1/integration/authorizeSubmit a spend request for policy evaluation. The response state (see Trust model) tells you whether spend was authorized, and carries the authorization ID you must use for capture/cancel/reverse.
| Field | Type | Required | Description |
|---|---|---|---|
| X-API-Key | string | Yes | Your API key. |
| Content-Type | string | Yes | application/json |
| Idempotency-Key | string | No | Optional. Alternative to the idempotencyKey body field (see below) — if both are set, the header takes precedence. |
| Field | Type | Required | Description |
|---|---|---|---|
| agentId | string (UUID) | Yes | UUID of the registered agent making the spend request. |
| amount | number | Yes | Transaction amount. Must be ≥ 0.01. |
| currency | string (ISO 4217) | Yes | 3-letter currency code, e.g. USD, EUR, GBP. |
| merchantId | string | No | Optional internal merchant identifier. |
| merchantName | string | No | Human-readable merchant name used for merchant-based policy rules. |
| mccCode | string | No | 4-digit Merchant Category Code. Used for MCC-based policy rules (e.g. 7372 for Computer Programming). |
| idempotencyKey | string | No | Unique key for safe retries. Duplicate requests with the same key return the original response without re-evaluating policy. |
This is the terse, agent-facing shape — no rule breakdown or configured limits. See Trust model for why. The full rule-evaluation detail is dashboard-only — there is no API endpoint, for any credential type, that returns it.
| Field | Type | Nullable | Description |
|---|---|---|---|
| authorizationId | string (UUID) | No | Unique ID for this authorization. Use in confirm, cancel, and reverse calls. |
| agentId | string (UUID) | No | The agent that made the request. |
| agentName | string | No | Display name of the agent, resolved server-side. Falls back to the agentId string itself in the rare case the agent record can't be resolved — never actually null or omitted. |
| state | string (enum) | No | The sole source of truth for this authorization's status. APPROVED+AUTO → CAPTURED immediately. APPROVED+EXPLICIT → AUTHORIZED (reserved). Policy rejects → DECLINED_BY_POLICY. See State values. |
| amount | number | No | Authorized amount. |
| currency | string (ISO 4217) | No | Currency of the authorization. |
| merchantId | string | Yes | Echo of merchantId sent in the request. Null if omitted. |
| merchantName | string | Yes | Echo of merchantName sent in the request. Null if omitted. |
| mccCode | string | Yes | Echo of mccCode sent in the request. Null if omitted. |
| jwtToken | string (JWT) | Yes | Signed RS256 JWT for offline verification via JWKS. Present when state is AUTHORIZED or CAPTURED. Null for DECLINED_BY_POLICY, DECLINED_BY_HUMAN, and REQUIRES_APPROVAL. |
| reasonCode | string | Yes | Short machine-readable decline reason (e.g. MCC_NOT_ALLOWED). Non-null only when state is DECLINED_BY_POLICY or DECLINED_BY_HUMAN — see the reasonCode table in Trust model. Never the full rule breakdown or configured limits. |
| requiresHumanApproval | boolean | No | True when state is REQUIRES_APPROVAL — the transaction exceeded the policy's approval threshold and is awaiting a dashboard reviewer. Not yet approved and not declined. |
| expiresAt | string (ISO 8601) | Yes | Expiry timestamp. Non-null for AUTHORIZED and CAPTURED states. Null for DECLINED_BY_POLICY, DECLINED_BY_HUMAN, and REQUIRES_APPROVAL. |
| createdAt | string (ISO 8601) | No | Creation timestamp. |
curl -X POST https://api.paitify.io/v1/integration/authorize \
-H "X-API-Key: sk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"amount": 249.99,
"currency": "USD",
"merchantName": "AWS",
"mccCode": "7372",
"idempotencyKey": "order-2024-07-04-001"
}'{
"authorizationId": "9e1d2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"agentName": "AWS Procurement Bot",
"state": "CAPTURED",
"amount": 249.99,
"currency": "USD",
"merchantId": null,
"merchantName": "AWS",
"mccCode": "7372",
"jwtToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"reasonCode": null,
"requiresHumanApproval": false,
"expiresAt": "2024-07-04T10:15:00Z",
"createdAt": "2024-07-04T10:00:00Z"
}{
"authorizationId": "9e1d2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"agentName": "AWS Procurement Bot",
"state": "AUTHORIZED",
"amount": 249.99,
"currency": "USD",
"merchantId": null,
"merchantName": "AWS",
"mccCode": "7372",
"jwtToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"reasonCode": null,
"requiresHumanApproval": false,
"expiresAt": "2024-07-04T10:15:00Z",
"createdAt": "2024-07-04T10:00:00Z"
}{
"authorizationId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"agentName": "AWS Procurement Bot",
"state": "DECLINED_BY_POLICY",
"amount": 249.99,
"currency": "USD",
"merchantId": null,
"merchantName": "AWS",
"mccCode": "7372",
"jwtToken": null,
"reasonCode": "SPEND_LIMIT_DAILY",
"requiresHumanApproval": false,
"expiresAt": null,
"createdAt": "2024-07-04T10:00:00Z"
}state: "REQUIRES_APPROVAL". A dashboard reviewer must approve or deny it. In AUTO mode, approval immediately moves the state to CAPTURED. In EXPLICIT mode, approval moves it to AUTHORIZED — the same reserved state as a direct authorize — so the agent must still call /capture. Denial moves it to DECLINED_BY_HUMAN. Poll the authorizationId or listen for the authorization.captured, authorization.approved, or authorization.declined_by_human webhook events./v1/integration/authorize/{authorizationId}/captureEXPLICIT mode only. Capture an AUTHORIZED authorization after the underlying payment succeeds. Commits the reserved spend to budget counters and moves the authorization to CAPTURED.
| Field | Type | Required | Description |
|---|---|---|---|
| authorizationId | string (UUID) | Yes | The authorizationId returned by POST /v1/integration/authorize. |
| Field | Type | Required | Description |
|---|---|---|---|
| agentId | string (UUID) | Yes | Must match the agentId used in the original authorize call. |
Returns the full AuthorizeSpendResponse object. Notable differences from the authorize response:
| Field | Type | Nullable | Description |
|---|---|---|---|
| state | string | No | Always "CAPTURED". Spend committed to budget counters. |
| jwtToken | string | Yes | Always null — JWTs are issued only by POST /v1/integration/authorize. |
| reasonCode | string | Yes | Always null — only a state that was never declined can be captured. |
| requiresHumanApproval | boolean | No | Always false — a request still awaiting approval cannot be captured. |
| expiresAt | string (ISO 8601) | No | The original expiry timestamp. Non-null because only an AUTHORIZED authorization can be captured. |
curl -X POST https://api.paitify.io/v1/integration/authorize/9e1d2f3a-.../capture \
-H "X-API-Key: sk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c" }'{
"authorizationId": "9e1d2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"agentName": "AWS Procurement Bot",
"state": "CAPTURED",
"amount": 249.99,
"currency": "USD",
"merchantId": null,
"merchantName": "AWS",
"mccCode": "7372",
"jwtToken": null,
"reasonCode": null,
"requiresHumanApproval": false,
"expiresAt": "2024-07-04T10:15:00Z",
"createdAt": "2024-07-04T10:00:00Z"
}/v1/integration/authorize/{authorizationId}/cancelEXPLICIT mode only. Release an AUTHORIZED reservation before spend is ever captured. The reserved amount is returned to the agent's budget. Use when the agent decides not to proceed, or a human-approved authorization is abandoned before /capture.
| Field | Type | Required | Description |
|---|---|---|---|
| authorizationId | string (UUID) | Yes | The authorizationId returned by POST /v1/integration/authorize. |
| Field | Type | Required | Description |
|---|---|---|---|
| agentId | string (UUID) | Yes | Must match the agentId used in the original authorize call. |
| reason | string | No | Optional reason for cancelling, stored in the audit log. |
Returns the full AuthorizeSpendResponse object. Notable differences from the authorize response:
| Field | Type | Nullable | Description |
|---|---|---|---|
| state | string | No | Always "CANCELLED". Reservation released, budget restored. |
| jwtToken | string | Yes | Always null — JWTs are issued only by POST /v1/integration/authorize. |
| reasonCode | string | Yes | Always null — only a state that was never declined can be cancelled. |
| requiresHumanApproval | boolean | No | Always false — a request still awaiting approval cannot be cancelled. |
| expiresAt | string (ISO 8601) | No | The original expiry timestamp from when the authorization was created. |
curl -X POST https://api.paitify.io/v1/integration/authorize/9e1d2f3a-.../cancel \
-H "X-API-Key: sk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"reason": "Agent abandoned the order before checkout."
}'{
"authorizationId": "9e1d2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"agentName": "AWS Procurement Bot",
"state": "CANCELLED",
"amount": 249.99,
"currency": "USD",
"merchantId": null,
"merchantName": "AWS",
"mccCode": "7372",
"jwtToken": null,
"reasonCode": null,
"requiresHumanApproval": false,
"expiresAt": "2024-07-04T10:15:00Z",
"createdAt": "2024-07-04T10:00:00Z"
}/v1/integration/authorize/{authorizationId}/reverseRoll back a CAPTURED authorization after spend was already captured. Budget counters are restored and the authorization moves to REVERSED. Use when a downstream payment fails, or the order is refunded, after the spend was captured.
/cancel only works before spend is captured (AUTHORIZED)./reverse is the counterpart for spend that's already been captured (CAPTURED only — including AUTO mode, which captures immediately on authorize).| Field | Type | Required | Description |
|---|---|---|---|
| authorizationId | string (UUID) | Yes | The authorizationId returned by POST /v1/integration/authorize. |
| Field | Type | Required | Description |
|---|---|---|---|
| agentId | string (UUID) | Yes | Must match the agentId used in the original authorize call. |
| reason | string | No | Optional reason stored in the audit log. |
Returns the full AuthorizeSpendResponse object. Key field:
| Field | Type | Nullable | Description |
|---|---|---|---|
| state | string | No | Always "REVERSED". Budget counters have been reversed. |
| jwtToken | string | Yes | Always null. |
| reasonCode | string | Yes | Always null. |
| requiresHumanApproval | boolean | No | Always false. |
curl -X POST https://api.paitify.io/v1/integration/authorize/9e1d2f3a-.../reverse \
-H "X-API-Key: sk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"reason": "Payment gateway returned a decline."
}'{
"authorizationId": "9e1d2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"agentName": "AWS Procurement Bot",
"state": "REVERSED",
"amount": 249.99,
"currency": "USD",
"merchantId": null,
"merchantName": "AWS",
"mccCode": "7372",
"jwtToken": null,
"reasonCode": null,
"requiresHumanApproval": false,
"expiresAt": "2024-07-04T10:15:00Z",
"createdAt": "2024-07-04T10:00:00Z"
}/v1/integration/authorizations/{authorizationId}The live-state check the Trust model requires: confirm an authorization's current state before honoring a jwtToken you're still holding, since cancel/reverse/human-denial change state without revoking the token.
| Field | Type | Required | Description |
|---|---|---|---|
| authorizationId | string (UUID) | Yes | The authorizationId returned by POST /v1/integration/authorize. |
Returns the same terse AuthorizeSpendResponse shape as POST /v1/integration/authorize. Notable differences from that response:
| Field | Type | Nullable | Description |
|---|---|---|---|
| jwtToken | string (JWT) | Yes | Always null — a status check never (re-)issues a token. Verify the token you already hold against JWKS; use this endpoint only to confirm state. |
| expiresAt | string (ISO 8601) | Yes | The original expiry timestamp, unchanged by any later capture/cancel/reverse/expiry — not cleared or updated on state transitions. Null only for states that never had one: DECLINED_BY_POLICY, DECLINED_BY_HUMAN, REQUIRES_APPROVAL. |
X-API-Key — a Clerk-authenticated dashboard session gets 401 here, not a larger response. The full rule-evaluation detail, denial text, policy identity, and audit trail live on a separate, dashboard-only route (GET /v1/dashboard/authorizations/{id}) that isn't part of this API reference and is never reachable with an X-API-Key.Returned if authorizationIddoesn't exist, belongs to a different company than your API key's, or is older than your plan's data-retention window — identical either way, so a wrong ID and someone else's ID are indistinguishable.
curl https://api.paitify.io/v1/integration/authorizations/9e1d2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a \ -H "X-API-Key: sk_xxxxxxxxxxxx"
{
"authorizationId": "9e1d2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"agentName": "AWS Procurement Bot",
"state": "CAPTURED",
"amount": 249.99,
"currency": "USD",
"merchantId": null,
"merchantName": "AWS",
"mccCode": "7372",
"jwtToken": null,
"reasonCode": null,
"requiresHumanApproval": false,
"expiresAt": "2024-07-04T10:15:00Z",
"createdAt": "2024-07-04T10:00:00Z"
}/v1/integration/agents/{agentId}/budgetReturns the remaining budget for an agent across all configured spend-limit periods (daily, weekly, monthly). Useful for agents that want to self-check before spending.
| Field | Type | Required | Description |
|---|---|---|---|
| agentId | string (UUID) | Yes | UUID of the agent to query. |
| Field | Type | Nullable | Description |
|---|---|---|---|
| agentId | string (UUID) | No | The agent queried. |
| companyId | string (UUID) | No | Your company identifier. |
| spendLimits | SpendLimitStatus[] | No | One entry per configured period. Empty array if the agent has no spending limits defined. |
| spendLimits[].period | "DAILY" | "WEEKLY" | "MONTHLY" | No | The period this limit applies to. |
| spendLimits[].limitAmount | number | No | Maximum allowed spend for the period. |
| spendLimits[].currency | string (ISO 4217) | No | Currency of the limit. |
| spendLimits[].spent | number | No | Amount spent so far in the current period (CAPTURED authorizations only). |
| spendLimits[].remaining | number | No | limitAmount − spent. May be negative if limits were tightened mid-period. |
curl https://api.paitify.io/v1/integration/agents/3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c/budget \ -H "X-API-Key: sk_xxxxxxxxxxxx"
{
"agentId": "3f4a1b2c-d5e6-7f8a-9b0c-1d2e3f4a5b6c",
"companyId": "c0ffee00-cafe-babe-dead-beefdeadbeef",
"spendLimits": [
{
"period": "DAILY",
"limitAmount": 500.00,
"currency": "USD",
"spent": 249.99,
"remaining": 250.01
},
{
"period": "MONTHLY",
"limitAmount": 5000.00,
"currency": "USD",
"spent": 1320.50,
"remaining": 3679.50
}
]
}/.well-known/jwks.jsonPublic endpoint — no API key required. Returns the RSA public key set used to verify jwtToken values issued by POST /v1/integration/authorize. Useful for offline verification in payment orchestration layers.
| Field | Type | Nullable | Description |
|---|---|---|---|
| keys | JWK[] | No | Array of JSON Web Keys in JWKS format (RFC 7517). Typically one key. |
| keys[].kty | string | No | Key type. Always "RSA". |
| keys[].use | string | No | Intended use. Always "sig" (signature verification). |
| keys[].alg | string | No | Algorithm. Always "RS256". |
| keys[].kid | string | No | Key ID. Match against the kid claim in the JWT header to select the right key. |
| keys[].n | string | No | RSA modulus (Base64url-encoded). |
| keys[].e | string | No | RSA public exponent (Base64url-encoded). |
curl https://api.paitify.io/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "paitify-2024",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4...",
"e": "AQAB"
}
]
}import jwt
import requests
from jwt.algorithms import RSAAlgorithm
# Fetch keys once and cache them
jwks = requests.get("https://api.paitify.io/.well-known/jwks.json").json()
public_key = RSAAlgorithm.from_jwk(jwks["keys"][0])
# Verify the jwtToken from the authorize response
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
options={"verify_exp": True}
)
# payload contains: authorizationId, agentId, amount, currency, iat, expWebhooks
Configure a webhook URL in the dashboard (Settings → Webhooks) to get a real-time HTTP POST whenever an authorization changes state — useful for states that can change after your original API call returns, like a pending human approval being resolved. You can also poll GET /v1/integration/authorizations/{id} directly (see API Reference) instead of or in addition to webhooks. Only one webhook can be active per company at a time. Events fired:
| Event | Fires when |
|---|---|
| authorization.approved | Policy check passed and the authorization was approved (AUTO or EXPLICIT). Also fires when a human reviewer approves an EXPLICIT-mode authorization, since that lands it on the same AUTHORIZED state as a direct approval. |
| authorization.denied | Policy check failed, or no active policy exists for the agent. |
| authorization.requires_approval | Approval threshold exceeded — held for a dashboard reviewer. |
| authorization.captured | Spend committed to budget counters (AUTO authorize, or /capture). |
| authorization.cancelled | Reservation released via /cancel before spend was ever captured. |
| authorization.reversed | Captured spend rolled back via /reverse — budget restored. |
| authorization.declined_by_human | Dashboard reviewer denied a pending approval. |
Every delivery is signed with the webhook's signing secret (shown once when the webhook is created, and re-viewable any time from Settings → Webhooks). Two headers are sent with every request:
| X-Paitify-Signature | t=<unix-seconds>,v1=<hex-hmac-sha256> — Stripe-style signature. t is the delivery timestamp; v1 is the HMAC-SHA256 of {t}.{raw request body}, keyed with your webhook secret. |
| X-Paitify-Event | The event name, e.g. authorization.captured. |
To verify a delivery:
- Parse
X-Paitify-Signatureinto itstandv1parts. - Reject the delivery if
tis more than a few minutes old (5 minutes is a reasonable tolerance) — this stops replayed deliveries. - Recompute HMAC-SHA256 of
{t}.{raw body}using your webhook secret, over the exact raw bytes you received — not a re-serialized copy. - Compare your digest to
v1using a constant-time comparison. If it doesn't match, reject the delivery.
import hashlib
import hmac
import time
WEBHOOK_SECRET = "whsec_..." # from Settings → Webhooks
TOLERANCE_SECONDS = 300
def verify_webhook(raw_body: bytes, signature_header: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp, signature = parts["t"], parts["v1"]
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False # too old — possible replay
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(WEBHOOK_SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# In your HTTP handler, verify BEFORE parsing the JSON body:
# ok = verify_webhook(request.raw_body, request.headers["X-Paitify-Signature"])WEBHOOK_SECRET wherever you verify signatures.Error Reference
Every error uses the same JSON envelope — including one written directly by the rate limiter or the auth layer, not just ones from a request handler. The errorcolumn below is the exact string in the response body's error field — match on that programmatically, not on message, which is prose and may change.
| Status | error | When it occurs |
|---|---|---|
| 200 | — | Success. Note: a declined authorization still returns 200 - check the state field in the body (DECLINED_BY_POLICY / DECLINED_BY_HUMAN). |
| 400 | VALIDATION_ERROR | A request-body field fails bean validation (e.g. amount < 0.01, currency not 3 chars). details is populated with one message per failing field. |
| 400 | BAD_REQUEST | The request body is missing or malformed JSON, a query/path parameter has the wrong type, or another argument is invalid. |
| 401 | UNAUTHORIZED | The X-API-Key header (or bearer token) is missing, or the key does not exist / is inactive. |
| 402 | USAGE_LIMIT_EXCEEDED | Your plan's monthly authorization-request quota was reached. POST /v1/integration/authorize only - see the note in Trust model above. |
| 403 | FORBIDDEN | The credential is valid but its role does not permit this action - e.g. an agent's API key calling a dashboard-only endpoint. |
| 404 | NOT_FOUND | authorizationId does not exist (or belongs to another company, or is older than your plan's retention window - all three look identical), or agentId in a request body is not registered under your company. |
| 409 | CONFLICT | The authorization is not in a state this action allows (e.g. capturing one that's already CAPTURED, reversing one that was never captured). Re-fetch current state via GET /v1/integration/authorizations/{id} before retrying. |
| 429 | RATE_LIMIT_EXCEEDED | Rate limit exceeded. Back off and retry after the interval in the Retry-After response header (seconds); X-RateLimit-Limit/Remaining/Reset are also always set. |
| 503 | SERVICE_UNAVAILABLE | A downstream dependency (database or spend-counter store) is unavailable. The request was not processed - safe to retry with the same idempotency key shortly. |
| 500 | INTERNAL_ERROR | An unexpected server error. Contact support if the issue persists. |
All four fields are always present. details is an empty array except on VALIDATION_ERROR.
{
"error": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
"amount: must be greater than or equal to 0.01",
"currency: size must be between 3 and 3"
],
"timestamp": "2024-07-04T10:00:00Z"
}Idempotency
Include a unique idempotencyKey in the authorize request body — or an Idempotency-Key header, which takes precedence if both are set — to make retries safe. If the network drops after you send the request but before you receive the response, resend the identical request with the same key — you will get the original response without re-evaluating policy or double-counting spend.
IDEMPOTENCY_KEY_CONFLICT — always use a fresh key per distinct spend attempt.State Values
| State | Terminal | Meaning |
|---|---|---|
| AUTHORIZED | No | EXPLICIT mode: authorization approved (directly, or after a human reviewer approves), amount reserved. Agent has 15 minutes to call /capture or /cancel. |
| REQUIRES_APPROVAL | No | Held for a dashboard reviewer. A webhook fires once the reviewer approves or denies. |
| CAPTURED | No | Spend committed to budget counters. AUTO mode sets this on authorize (or human approval); EXPLICIT mode sets it on /capture. Reversible via /reverse. |
| CANCELLED | Yes | Reservation released via /cancel before spend was ever captured (AUTHORIZED). |
| REVERSED | Yes | Captured spend rolled back via /reverse (CAPTURED only). Budget restored. |
| DECLINED_BY_POLICY | Yes | Policy engine rejected the authorization at authorize time. Budget not affected. |
| DECLINED_BY_HUMAN | Yes | Dashboard reviewer denied the transaction. Budget not affected. |
| EXPIRED | Yes | AUTHORIZED timed out before the agent acted. Reserved amount released by the scheduler. |