openapi: 3.1.0 info: title: ShotPay Checkout API version: 2.0.0 description: | The contract between a merchant's storefront and ShotPay. Every platform plugin — the BigCommerce app today, WooCommerce, Magento and Shopify adapters later — builds against this document rather than against the endpoints directly. ## Authentication Requests carry two headers, and both are required: - `X-Client-Id` — the merchant's public identifier for one mode, `mch_test_` or `mch_live_` followed by 32 characters. It names the merchant the request speaks for. Not a secret. - `X-Api-Key` — a merchant API key: `sk_test_` or `sk_live_` followed by 32 characters. Keys are issued from the merchant dashboard and shown once; a lost key is replaced rather than recovered. The key is looked up beneath the client id, so a key presented against a client id it does not belong to authenticates nothing. A rejected pair answers `401` without saying which half was wrong. A merchant holds one client id per mode, and the two headers must agree on which mode they belong to — a sandbox client id sent with a live key is refused. Keys are **server-side secrets** — these endpoints are called from a merchant's backend, never from a customer's browser. The client id is not a secret; it identifies, it does not authorise. ## Sandbox and live Sandbox and live are two separate accounts that happen to share a login. A key issued in one never sees anything belonging to the other, and settings and webhook endpoints are held per mode. The key prefix decides which one a request runs in: `sk_test_` for sandbox, `sk_live_` for live. There is nothing to opt into. Sandbox payments are simulated. Nothing is charged, every attempt succeeds, and each one is stamped with a `sim_` reference. Webhook deliveries carry `livemode: false` for everything that happened there. ## Amounts Amounts are integer minor units (cents): `42900` is $429.00. `total_amount` must be a whole number of cents — a decimal is refused. servers: - url: '{origin}/api' variables: origin: default: https://api.shotpay.com description: | The ShotPay deployment being called. One host serves both modes — sandbox testing is a test-mode API key, not a different URL. security: - ApiKeyAuth: [] ClientIdAuth: [] tags: - name: Configuration description: | What a plugin reads at install time — whether its key works, and the terms it is allowed to offer. Both are authenticated by client id and API key. - name: Checkout Sessions description: | Opening a checkout from your backend and verifying what happened to it. Everything between the two — the quote, accepting the plan, cards, payments — happens on the ShotPay-hosted page, and your store hears about it through webhooks and the verify read. - name: Contracts description: | Your agreements after checkout: list and read them, correct bookkeeping fields, and cancel. Addressed by the `reference` webhook payloads carry, and behind their own `contracts:read` and `contracts:write` scopes, so a checkout key pasted into a storefront cannot also end plans. paths: /v1/ping: get: tags: [Configuration] operationId: ping summary: Confirm a key authenticates and see what it resolved to description: | The diagnostic a merchant runs after pasting a freshly issued key. Says which merchant the key speaks for and whether it may currently sell. security: - ApiKeyAuth: [checkout:read] ClientIdAuth: [] responses: '200': description: The key is valid. content: application/json: schema: type: object required: [data] properties: data: type: object required: [client_id, legal_name, dba, scopes, key_prefix, layaway] properties: client_id: type: string example: mch_live_7fL2qXn4WbTzR9kD1sVyH6mCgA8eUpJ3 legal_name: type: string example: Frontier Firearms LLC dba: type: [string, 'null'] example: Frontier Range scopes: type: array items: $ref: '#/components/schemas/Scope' key_prefix: type: string description: The first 12 characters of the key, for identifying it in a list. example: sk_live_a1b2 layaway: $ref: '#/components/schemas/LayawayAvailability' '401': $ref: '#/components/responses/Unauthenticated' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/TooManyRequests' /v1/config: get: tags: [Configuration] operationId: retrieveConfig summary: Read the merchant display data and selling terms description: | What a plugin reads on install to verify itself and render a badge. Answers `200` even when layaway is unavailable — an install check asking "can I sell?" needs to be told why not. Read `layaway.available` rather than treating the status code as the answer. `terms` is the range this merchant may sell in. Use it to keep an out-of-bounds cart from ever reaching session create. security: - ApiKeyAuth: [checkout:read] ClientIdAuth: [] responses: '200': description: The merchant's configuration. content: application/json: schema: type: object required: [data] properties: data: type: object required: [merchant, mode, layaway, terms, session_ttl_minutes] properties: merchant: type: object required: [client_id, legal_name, dba] properties: client_id: type: string example: mch_live_7fL2qXn4WbTzR9kD1sVyH6mCgA8eUpJ3 legal_name: type: string example: Frontier Firearms LLC dba: type: [string, 'null'] example: Frontier Range mode: $ref: '#/components/schemas/Mode' layaway: $ref: '#/components/schemas/LayawayAvailability' terms: type: object required: [min_order_amount, max_order_amount, down_payment_percentage] properties: min_order_amount: type: integer description: Integer cents. example: 5000 max_order_amount: type: integer description: Integer cents. example: 600000 down_payment_percentage: type: integer description: The share of the order total collected at checkout. example: 25 session_ttl_minutes: type: integer description: How long a new session stays open before it lapses. example: 60 '401': $ref: '#/components/responses/Unauthenticated' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/TooManyRequests' /v1/sessions: post: tags: [Checkout Sessions] operationId: createCheckoutSession summary: Open a checkout session description: | Announces an order and returns the hosted checkout link to send the customer to. The session starts `pending` and lapses after `session_ttl_minutes` unless it advances. A lapsed session cannot be transacted on, whether or not its stored status has caught up. Two refusals are distinct and should be handled differently: - **403** — layaway is unavailable for this merchant or this customer's jurisdiction. `reason` names which condition failed and `message` is written for the customer to read. Not retryable; hide the option. - **422** — the request itself is wrong, most often an order total outside the merchant's effective range. Fixable by the caller. Sending the same `order_reference` twice creates two sessions — a customer who abandons and comes back is meant to get a fresh one rather than be turned away. Track the returned `id` if you need to correlate them. Send an `Idempotency-Key` to make a retry safe instead: replaying the same key with the same request body returns the original session rather than opening a second one, which is what covers a timeout you never saw the response to. The same key with a *different* body is a **409** — it will not answer for a request you did not send. Keys are scoped to the merchant and mode that presented them and expire after 24 hours, after which the same value is treated as new. security: - ApiKeyAuth: [checkout:write] ClientIdAuth: [] parameters: - name: Idempotency-Key in: header required: false description: | Makes a retry of this exact request safe. One value per checkout attempt, reused across retries of that attempt. schema: type: string maxLength: 255 example: 6f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateCheckoutSessionRequest' responses: '201': description: | The session was opened, or an `Idempotency-Key` was replayed and the session it originally opened is returned unchanged. content: application/json: schema: type: object required: [data] properties: data: $ref: '#/components/schemas/CheckoutSession' '400': description: | The `Idempotency-Key` is longer than 255 characters. content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthenticated' '403': description: | The key lacks `checkout:write`, the merchant is not cleared to transact, or layaway is unavailable. Only the last carries `reason`. content: application/json: schema: oneOf: - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/LayawayRefusal' '409': $ref: '#/components/responses/IdempotencyKeyConflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/TooManyRequests' /v1/checkout-sessions/{id}: get: tags: [Checkout Sessions] operationId: retrieveCheckoutSession summary: Retrieve a session from your server description: | Asks what happened to a session you opened, without waiting for a webhook to arrive. The server-to-server counterpart to `POST /v1/sessions`, returning the same shape it returned. Distinct from `GET /v1/sessions/{id}`, which is the hosted page's unauthenticated read and answers a narrower payload. The two cannot share a path, which is why this one sits under `checkout-sessions`. Webhooks remain the right way to drive fulfilment. Use this for the moments where asking is what you want: a customer landing back on your success page, a support ticket, or a reconciliation job. `status` is the **effective** status. A session past its deadline reads as `expired` here whether or not the sweeper has written that down yet. A token belonging to another merchant answers `404` rather than `403`, and so does one of your own from the other mode — the API does not confirm a token exists to a caller not entitled to read it. security: - ApiKeyAuth: [checkout:read] ClientIdAuth: [] parameters: - name: id in: path required: true description: The session's public token, as returned by session create. schema: type: string example: cs_9Xk2mQpR7vLzT4hB1nWsY6dF3jCgA8eU responses: '200': description: The session. content: application/json: schema: type: object required: [data] properties: data: $ref: '#/components/schemas/CheckoutSession' '401': $ref: '#/components/responses/Unauthenticated' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' /v1/contracts: get: tags: [Contracts] operationId: listContracts summary: List your agreements description: | Newest first, paginated. A sandbox key lists sandbox agreements and a live key live ones; agreements born from dashboard checkout previews never appear. security: - ApiKeyAuth: [contracts:read] ClientIdAuth: [] parameters: - name: status in: query required: false description: Only agreements in this status. schema: $ref: '#/components/schemas/ContractStatus' - name: page in: query required: false schema: type: integer minimum: 1 responses: '200': description: One page of agreements. content: application/json: schema: type: object required: [data, links, meta] properties: data: type: array items: $ref: '#/components/schemas/Contract' links: type: object description: First, last, previous and next page URLs. meta: type: object description: Page numbers and totals. '401': $ref: '#/components/responses/Unauthenticated' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/TooManyRequests' /v1/contracts/{reference}: get: tags: [Contracts] operationId: retrieveContract summary: Retrieve an agreement description: | One agreement with its full schedule. A reference belonging to another merchant answers `404` rather than `403`, and so does one of your own from the other mode — the API does not confirm a reference exists to a caller not entitled to read it. security: - ApiKeyAuth: [contracts:read] ClientIdAuth: [] parameters: - name: reference in: path required: true description: The agreement's public reference, as carried by webhooks. schema: type: string example: LAY-XK2M9QPR7V responses: '200': description: The agreement. content: application/json: schema: type: object required: [data] properties: data: $ref: '#/components/schemas/Contract' '401': $ref: '#/components/responses/Unauthenticated' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' patch: tags: [Contracts] operationId: updateContract summary: Update an agreement's bookkeeping fields description: | Corrects what your own systems know the agreement as. Money, status and the customer's identity are not writable — anything not named in the request schema never reaches the record. security: - ApiKeyAuth: [contracts:write] ClientIdAuth: [] parameters: - name: reference in: path required: true schema: type: string example: LAY-XK2M9QPR7V requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateContractRequest' responses: '200': description: The updated agreement. content: application/json: schema: type: object required: [data] properties: data: $ref: '#/components/schemas/Contract' '401': $ref: '#/components/responses/Unauthenticated' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/TooManyRequests' /v1/contracts/{reference}/cancel: post: tags: [Contracts] operationId: cancelContract summary: Cancel an agreement description: | Ends an active agreement. No further installments are collected; paid installments stay paid — what happens to money already collected is between you and your customer. A `contract.cancelled` webhook goes to your subscribed endpoints, and cancelling an already-cancelled agreement answers `200` with the agreement as it stands. security: - ApiKeyAuth: [contracts:write] ClientIdAuth: [] parameters: - name: reference in: path required: true schema: type: string example: LAY-XK2M9QPR7V responses: '200': description: The agreement, now cancelled. content: application/json: schema: type: object required: [data] properties: data: $ref: '#/components/schemas/Contract' '401': $ref: '#/components/responses/Unauthenticated' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': description: | The agreement already resolved some other way — completed or defaulted — and cannot be cancelled. content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/TooManyRequests' components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-Api-Key description: | A merchant API key, issued from the ShotPay merchant dashboard. The prefix decides the mode the request runs in: `sk_test_` for sandbox, `sk_live_` for live. ClientIdAuth: type: apiKey in: header name: X-Client-Id description: | The merchant's public client id for the mode being used, shown on the API keys page of the merchant dashboard — switching the dashboard's mode toggle shows the other one. Listed alongside `ApiKeyAuth` in the same requirement object, so both headers are required together, and the two must belong to the same mode. schemas: Scope: type: string enum: [checkout:read, checkout:write, contracts:read, contracts:write] Mode: type: string description: | Which half of the account the request ran in, decided by the key prefix rather than by anything in the request. enum: [sandbox, live] CheckoutSessionStatus: type: string description: | `pending` may become `decision_made`, `cancelled` or `expired`. `decision_made` may become `completed` or `cancelled`. The last three are terminal. Only a pending session expires. enum: [pending, decision_made, completed, cancelled, expired] LayawayBlockReason: type: string description: Why layaway is unavailable. Absent when it is available. enum: - platform_disabled - merchant_overridden - state_unavailable - state_excluded - merchant_disabled - merchant_inactive LayawayAvailability: type: object required: [available, reason, message] properties: available: type: boolean reason: oneOf: - $ref: '#/components/schemas/LayawayBlockReason' - type: 'null' message: type: [string, 'null'] description: A customer-safe explanation. Null when layaway is available. example: Layaway is not currently offered in your state. Metadata: type: object description: | Your own key/value pairs, as sent when the session was opened. Echoed back untouched — the platform never reads them. Always present, and `{}` when none were sent. additionalProperties: type: string example: order_id: '1001' warehouse: SEA-2 MetadataInput: type: [object, 'null'] description: | Your own key/value pairs, stored on the session and copied onto the contract it becomes. Echoed back untouched — the platform never reads them. Set when the session is opened, and correctable afterwards with `PATCH /v1/contracts/{reference}`, which replaces the map rather than merging into it. At most 50 keys. Keys are at most 40 characters, values at most 500, and every value must be a string. Values are trimmed; send no empty values — omit the key instead. Send an object rather than a list. maxProperties: 50 additionalProperties: type: string maxLength: 500 propertyNames: maxLength: 40 example: order_id: '1001' warehouse: SEA-2 CreateCheckoutSessionRequest: type: object required: [order_reference, total_amount, return_url, cancel_url] properties: order_reference: type: string maxLength: 100 description: The merchant's own identifier for the order. example: ORD-1001 total_amount: type: integer exclusiveMinimum: 0 description: | The order total in integer minor units (cents) — send 49999 for $499.99. Must sit inside the merchant's effective range, which `GET /v1/config` reports as `terms`. example: 49999 return_url: type: string format: uri maxLength: 2048 description: Where the customer lands after completing checkout. HTTPS only. example: https://store.example/checkout/complete cancel_url: type: string format: uri maxLength: 2048 description: Where the customer lands if they back out. HTTPS only. example: https://store.example/cart customer_email: type: [string, 'null'] format: email maxLength: 255 customer_state: type: [string, 'null'] minLength: 2 maxLength: 2 description: | The two-letter US state the order ships to, which the jurisdiction rules are applied against. Omit it and the merchant's own state is used instead, which is the more conservative reading. example: TX metadata: $ref: '#/components/schemas/MetadataInput' CheckoutSession: type: object required: - id - status - order_reference - total_amount - down_payment_amount - down_payment_percentage - checkout_url - return_url - cancel_url - expires_at - created_at - metadata properties: id: type: string description: | The session's public token. No internal identifier is exposed here — this value reaches a customer's browser. example: cs_9Xk2mQpR7vLzT4hB1nWsY6dF3jCgA8eU status: $ref: '#/components/schemas/CheckoutSessionStatus' order_reference: type: string example: ORD-1001 total_amount: type: integer description: Integer cents. example: 49999 down_payment_amount: type: integer description: | Collected at checkout, in integer cents. Snapshotted when the session opened — a later change to the merchant's rate does not move it. example: 12500 down_payment_percentage: type: integer example: 25 checkout_url: type: string format: uri description: Where to send the customer. example: https://pay.shotpay.com/checkout/cs_9Xk2mQpR7vLzT4hB1nWsY6dF3jCgA8eU return_url: type: string format: uri cancel_url: type: string format: uri expires_at: type: string format: date-time created_at: type: string format: date-time metadata: $ref: '#/components/schemas/Metadata' PlanInstallment: type: object description: | One capture on the plan's schedule. `sequence` is the row's position; `type` says whether it is the Cart Capture — the opening payment, taken at checkout and never counted or retried — or an Installment Capture, numbered within the plan from 1 by `installment_number`. required: [sequence, type, installment_number, amount, due_date, status, paid_at] properties: sequence: type: integer description: The row's position in the schedule. 1 is the Cart Capture. example: 2 type: $ref: '#/components/schemas/CaptureType' installment_number: type: [integer, 'null'] description: The Installment Capture's number within the plan; null on the Cart Capture. example: 1 amount: type: integer description: Integer cents. example: 25000 due_date: type: string format: date example: '2026-08-25' status: $ref: '#/components/schemas/InstallmentStatus' paid_at: type: [string, 'null'] format: date-time CaptureType: type: string description: The Cart Capture opens the plan; every capture after it is an Installment Capture. enum: [cart_capture, installment_capture] ContractStatus: type: string description: | `pending` is an agreement whose Cart Capture has not landed — a declined down payment the customer may retry. `active` may become `completed`, `cancelled` or `defaulted`. All three are terminal. enum: [pending, active, completed, cancelled, defaulted] InstallmentStatus: type: string description: | `upcoming` becomes `due` when its deadline passes, then `paid` once collected. `past_due` is Attempt 2 having failed and the consumer having been notified — the moment both clocks start; `delinquent` is the grace period having run out and the late charge assessed. Both can still be settled. enum: [upcoming, due, paid, past_due, delinquent] Contract: type: object description: | A layaway agreement as your server sees it. Identified by `reference` — the same value webhook payloads carry — never by a row id. required: - reference - order_reference - status - customer_name - customer_email - customer_state - total_amount - reservation_fee_amount - customer_total - down_payment_amount - paid_amount - remaining_amount - installments - started_at - completed_at - defaulted_at - cancelled_at - created_at - metadata properties: reference: type: string description: The agreement's public reference. example: LAY-XK2M9QPR7V order_reference: type: [string, 'null'] description: Your own identifier, as sent when the session was opened. status: $ref: '#/components/schemas/ContractStatus' customer_name: type: string customer_email: type: string customer_state: type: [string, 'null'] description: The two-letter state the agreement was opened against. total_amount: type: integer description: The order total before the fee. Integer cents. example: 100000 reservation_fee_amount: type: integer description: The Reservation Fee, retained on default. Integer cents. example: 5000 customer_total: type: integer description: The order total plus the fee — what the plan collects in all. example: 105000 down_payment_amount: type: integer description: Collected at checkout, and the only capture the Reservation Fee rides on. Integer cents. example: 30000 paid_amount: type: integer example: 30000 remaining_amount: type: integer example: 75000 installments: type: array items: $ref: '#/components/schemas/PlanInstallment' started_at: type: [string, 'null'] format: date-time completed_at: type: [string, 'null'] format: date-time defaulted_at: type: [string, 'null'] format: date-time cancelled_at: type: [string, 'null'] format: date-time created_at: type: string format: date-time metadata: $ref: '#/components/schemas/Metadata' UpdateContractRequest: type: object description: | The bookkeeping your server may correct after the fact. Money, status and the customer's identity are not writable. properties: order_reference: type: string maxLength: 100 metadata: $ref: '#/components/schemas/MetadataInput' Error: type: object required: [message] properties: message: type: string LayawayRefusal: type: object required: [message, reason] properties: message: type: string description: Written for the customer to read. reason: $ref: '#/components/schemas/LayawayBlockReason' ValidationError: type: object required: [message, errors] properties: message: type: string errors: type: object additionalProperties: type: array items: type: string example: total_amount: - The order total must be between 5000 and 600000 cents. responses: IdempotencyKeyConflict: description: | This `Idempotency-Key` was already used with a different request body. Honouring it would answer for a request the caller never sent, so it is refused rather than replayed. Send the original body, or a new key. content: application/json: schema: $ref: '#/components/schemas/Error' Unauthenticated: description: The key is missing, unknown, or revoked. content: application/json: schema: $ref: '#/components/schemas/Error' Forbidden: description: | The merchant is not cleared to transact. content: application/json: schema: $ref: '#/components/schemas/Error' NotFound: description: | No session has that token. Also the answer for a token that was never valid, which is the same thing from the caller's side. content: application/json: schema: $ref: '#/components/schemas/Error' ValidationError: description: The request did not validate. content: application/json: schema: $ref: '#/components/schemas/ValidationError' TooManyRequests: description: | Rate limited. Traffic is bounded per source and per key; the `Retry-After` header says how long to wait. headers: Retry-After: schema: type: integer description: Seconds until the next request is allowed. content: application/json: schema: $ref: '#/components/schemas/Error'