---
title: "Buy credits with USDC over x402"
method: POST
path: "/credits/topup"
tags: ["Credits"]
---

# Buy credits with USDC over x402

`POST /credits/topup`

## Request

- Base URL: `https://inkseal.net/api/v1`
- URL: `https://inkseal.net/api/v1/credits/topup`
- Auth: HTTP bearer

Buys one credit pack with USDC, over the x402 payment protocol, without a human at a checkout page.

The exchange is two requests. Call it with a `packId` and no `PAYMENT-SIGNATURE` header: it answers `402` with a `PAYMENT-REQUIRED` header carrying the quote, one `accepts` entry per chain this deployment settles on, each naming the token, the amount in atomic units, the address to pay and how long the quote is good for. Sign one of those entries and call again with the signed payload in `PAYMENT-SIGNATURE`. The payment is verified, settled, and only then are credits granted, in one database transaction with the receipt.

A payment signature buys credits exactly once. Presenting it again answers `409` `payment_replayed` rather than settling twice, and the details say what became of the original, so a retry after a lost response is safe. Do not send an `Idempotency-Key`: this operation does not take one, because the payment's own nonce is the key.

Credits bought this way are **not refundable**. The card path, `createCreditCheckout`, remains the default and is the one a human pays.

This operation exists only where the deployment enables USDC top-ups. Where it does not, every call answers `404` `not_found`, whatever the credential holds.

## Headers

- `PAYMENT-SIGNATURE` string

## Request body

- object
  - `packId` 'pack_5' | 'pack_25' | 'pack_50' | 'pack_100' | 'pack_500', required: Which pack to buy. The sizes and prices are the ones published at https://inkseal.net/pricing, the same table `createCreditCheckout` sells from.

## Response `201`

The payment settled and the credits are in the balance. `PAYMENT-RESPONSE` carries the settlement receipt.

- object
  - `data` X402TopUp, required: A settled USDC purchase: what it bought, the balance it produced, and the on-chain payment behind it.
    - `credits` integer, required: Credits this payment added.
    - `balance` integer, required: The workspace balance after the credits were added.
    - `packId` 'pack_5' | 'pack_25' | 'pack_50' | 'pack_100' | 'pack_500', required: The pack that was sold.
    - `payment` object, required: The settlement, as the facilitator reported it.
      - `network` string, required: CAIP-2 chain id the payment settled on.
      - `transaction` string, required: The settlement transaction hash.
      - `payer` string, nullable, required: The address the tokens came from.
      - `amountUsd` string, required: The pack's USD face value at settlement, to the cent.

## Other responses

- `400`: The request is malformed or the resource is in the wrong state for a synchronous check. Codes: `validation_error`, `invalid_body`, `invalid_parameter`, `invalid_idempotency_key`, `invalid_event_id`, `invalid_file`, `invalid_url`, `invalid_status`, `key_mismatch`, `no_signers`, `not_completed`, `envelope_exists`, `document_not_ready`, `upload_failed`.
- `401`: The credential is missing, malformed, unknown, expired, revoked, or past its rotation grace window. Code: `unauthorized`. Every authentication failure is deliberately indistinguishable.
- `402`: `payment_required`. Either no payment was sent, in which case this is the quote, or the payment was refused, in which case `details.reason` carries the facilitator's reason and this is a fresh quote. Nothing was charged and the balance is unchanged either way. The `PAYMENT-REQUIRED` header is the machine-readable half.
- `403`: The credential is valid but may not do this. Codes: `insufficient_scope` (the route's scopes are not all held; `details` names `requiredScopes` and `missingScopes`), `forbidden` (the capability check on the resolved resource failed), `scope_escalation` (a grant wider than the caller's own was requested).
- `404`: The resource does not exist, was deleted, or belongs to another workspace. Code: `not_found`. The three cases are answered identically on purpose, so a 404 never confirms that an identifier exists elsewhere.
- `409`: The resource's state forbids this transition, or another request owns the work. Codes: `invalid_state`, `invalid_status`, `credential_state`, `limit_exceeded`, `envelope_missing`, `not_retryable`, `owner_unavailable`, `workspace_unavailable`, `detection_not_completed`, `document_in_use`, `version_conflict`, `document_processing`, `document_processing_failed`, `normalized_pdf_unavailable`, `pages_not_rendered`, `document_not_ready`, `idempotency_in_progress` (which also carries `Retry-After`), `payment_replayed` (a USDC payment signature presented twice), `payment_pending` (a USDC settlement whose outcome is not yet known).
- `429`: A limit was reached. Codes: `rate_limited` (the credential's per-bucket HTTP limit, or the per-workspace detection throttle), `workspace_limit_exceeded` (a workspace content or send limit; `details.limit` names it), `daily_budget_exceeded` (the workspace's daily detection budget). The four rate-limit headers are sent on this response and on no successful one, so a client cannot read its remaining budget without being refused. Retrying after `Retry-After` is always safe: a rate-limit refusal happens before the handler runs.
- `503`: A dependency the request needs is unreachable and nothing was changed. Codes: `queue_unavailable` (a background job could not be enqueued; retry the call), `billing_unavailable` (Stripe would not open a checkout session; nothing was charged, retry the call), `service_unavailable` (the rate-limit store is down, so the request was refused before running).

---

[API](https://skmtc.dev/inkseal/apis/inkseal-api.md) · [All operations](https://skmtc.dev/inkseal/apis/inkseal-api/llms.txt) · [OpenAPI document](https://skmtc.dev/inkseal/apis/inkseal-api/revisions/c440c981d578?raw)
