App API

Authentication, scopes, rate limits and the core endpoints.

Authentication

Bearer tokens, modes and installation context.

Base URL:

https://api.spuke.com/v1/app

Every request carries an installation access token:

curl "https://api.spuke.com/v1/app/v1/payments?limit=10" \
  -H "Authorization: Bearer spk_at_live_…"

The token identifies the app and the merchant. There is no merchant id parameter — you cannot address a merchant that has not installed you.

Getting a token

Tokens come from the OAuth flow at install time (authorization_code, PKCE supported) and are renewed with the refresh_token grant. See Installs & OAuth.

Property Value
Access token spk_at_live_… / spk_at_test_…, valid 1 hour (expires_in: 3600)
Refresh token spk_rt_live_… / spk_rt_test_…, single use and rotating — store the new one on every refresh
Mode Baked into the token, cannot be switched
Scopes Exactly what the merchant approved
Reissue Issuing a new access token revokes the installation's previous access tokens

Because refresh tokens rotate, refreshing twice with the same token invalidates the installation's tokens — serialise your refresh calls.

Failure modes

Status error.type Do this
401 invalid_token Refresh, then retry once
403 insufficient_scope Request the scope in a new version; do not retry
403 installation_suspended Back off, retry later, keep data
404 not_found Object does not exist for this merchant
404 unknown_endpoint Path typo or unsupported resource
405 method_not_allowed v1 is read-only; only GET
429 rate_limit_exceeded Honour Retry-After

Error bodies are uniform:

{ "error": { "type": "insufficient_scope", "message": "Missing required scope: payments:read" } }

Server-side only

The client secret and refresh tokens must never reach a browser or a mobile binary. Public clients use PKCE and keep only the short-lived access token.

Endpoints

Everything the App API exposes today.

The App API is how your app reads the merchant's data. Version 1 is read-only — every endpoint is a GET, anything else returns 405 method_not_allowed. Write capabilities are granted case by case after review.

Base URL: https://api.spuke.com/v1/app · Auth: Authorization: Bearer spk_at_live_… (see Authentication).

Endpoints

Method Path Scope What it gives you
GET /v1/merchant merchant:read Profile of the merchant that installed your app: company, country, VAT number, status. Call once after install to label the connection.
GET /v1/payments payments:read Processed payments, newest first. Live traffic supports ?status=succeeded.
GET /v1/payments/{id} payments:read One payment. The {id} is the charge_id in live and the row id in sandbox.
GET /v1/refunds refunds:read Sandbox: refund rows. Live: refunded charges with the running amount_refunded.
GET /v1/disputes disputes:read Chargebacks including evidence_due_by and past_due.
GET /v1/payouts payouts:read Bank settlements. Restricted scope — reconciliation tools only.
GET /v1/balance payouts:read Available and pending balance of the merchant.
GET /v1/products products:read The merchant's catalog (no prices in v1).
GET /v1/products/{id} products:read One product.
GET /v1/invoices invoices:read Issued invoices. Live only — sandbox returns an empty list.
GET /v1/invoices/{id} invoices:read One invoice.
GET /v1/subscriptions subscriptions:read Recurring agreements with status and period end.
GET /v1/analytics/summary analytics:read Aggregated volume for a window: ?days=30 (1–365).

Field-by-field descriptions of every response are in Objects & fields.

Query parameters

Parameter Where Behaviour
limit all list endpoints 1–100, default 25.
offset all list endpoints Row offset, default 0.
status /v1/payments (live) Exact match, e.g. ?status=succeeded.
days /v1/analytics/summary 1–365, default 30.

Unknown query parameters are ignored, not rejected. There are no date-range filters in v1 — page by offset and stop at the first object older than your cursor.

Pagination

curl "https://api.spuke.com/v1/app/v1/payments?limit=50&offset=100" \
  -H "Authorization: Bearer spk_at_live_…"
{ "object": "list", "data": [ … ], "has_more": true }

Lists are sorted newest first and carry no total count. Iterate until has_more is false. New objects arrive while you page, so for exports read the first page frequently rather than deep-paging a moving list.

Test vs live

The token decides the data source: a sandbox token reads your environment's simulated data, a live token reads the merchant's real data. Field names differ between the two — see the table at the top of Objects & fields.

Amounts and timestamps

Amounts are integer minor units with a lowercase ISO currency: { "amount": 2500, "currency": "eur" } is €25.00. Never use floats. Timestamps are ISO-8601 UTC; arrival_date, issue_date and due_date are dates without a time part.

Errors

{ "error": { "type": "insufficient_scope", "message": "Missing required scope: payments:read" } }

See Error & status codes.

Objects & fields

The shape of every resource you can read.

This page describes the actual JSON the App API returns, field by field.

One thing to internalise first: the API serves two different data sources depending on the token.

Token Data source Field style
spk_at_test_… (sandbox environment) Your simulated environment data amount_cents, created_at, UUID id
spk_at_live_… (installed merchant) The merchant's real processed data amount, charge_created_at, processor IDs

Both are documented below. Write your parser against both, or gate features by livemode.

Global rules

Rule What it means
Amounts are integers in minor units 2500 with "currency": "eur" is €25.00. Never use floats.
Currencies are lowercase ISO-4217 eur, chf, usd.
Timestamps are ISO-8601 UTC strings 2026-08-17T09:41:12Z. Date-only fields (arrival_date, due_date) have no time part.
IDs are opaque Sandbox rows use UUIDs, live rows use processor IDs (ch_…, pi_…, po_…). Never parse them.
Objects are additive Ignore unknown fields instead of failing.
object names the type payment, product, invoice, merchant, balance, analytics_summary, list.

Merchant — GET /v1/merchant

Who installed your app.

{ "object": "merchant", "id": "…", "business_name": "Ada GmbH", "email": "ops@ada.de",
  "country": "DE", "city": "Berlin", "vat_number": "DE123456789", "reference": "M-10423",
  "status": "approved", "plan_id": "…", "livemode": true, "created_at": "2026-05-02T08:00:00Z" }
Field Meaning
business_name Legal/company name from onboarding.
reference Short merchant reference used in support tickets.
status Onboarding state — only approved merchants process live payments.
plan_id The merchant's spuke plan; null in sandbox.
livemode false means you are talking to a sandbox test merchant.

Payment — GET /v1/payments, /v1/payments/{id}

One processed payment. In sandbox the row is a simulated checkout session; in live it is the real charge.

Live

{ "object": "payment", "livemode": true, "charge_id": "ch_3Nk91x", "payment_intent_id": "pi_3Nk91x",
  "amount": 2500, "amount_refunded": 0, "currency": "eur", "status": "succeeded", "refunded": false,
  "customer_email": "ada@example.com", "customer_name": "Ada Lovelace", "description": "Order #1042",
  "payment_method_type": "card", "card_brand": "visa", "card_last4": "4242", "card_country": "DE",
  "receipt_url": "https://…", "failure_code": null, "failure_message": null,
  "charge_created_at": "2026-08-17T09:41:12Z" }
Field Meaning
charge_id The identifier of this payment. Use it for /v1/payments/{id} and to match refunds/disputes.
payment_intent_id The intent the charge belongs to; matches payment_intent in webhook payloads.
amount Amount charged, minor units. Never changes.
amount_refunded Total refunded so far. amount - amount_refunded is what the merchant kept.
refunded true once fully refunded.
status succeeded (money captured), pending, failed. Only succeeded means paid.
customer_email / customer_name Buyer details from checkout — personal data.
payment_method_type card, twint, sepa_debit, paypal, …
card_brand / card_last4 / card_country Display only. Full card data never leaves the processor.
receipt_url Hosted receipt you can link to.
failure_code / failure_message Populated on failed payments.
charge_created_at When the payment was processed. Also the sort key of the list.

Sandbox

{ "object": "payment", "livemode": false, "id": "…uuid", "amount_cents": 2500,
  "amount_refunded_cents": 0, "fee_cents": 74, "net_cents": 2426, "currency": "eur",
  "status": "completed", "customer_email": "test@spuke.test", "customer_name": "Test Buyer",
  "description": "Order #1", "payment_method_type": "card", "card_brand": "visa", "card_last4": "4242",
  "failure_code": null, "failure_message": null, "disputed": false,
  "payment_intent_id": "pi_test_…", "charge_id": "ch_test_…", "created_at": "2026-08-17T09:41:12Z" }
Extra field Meaning
fee_cents / net_cents Simulated spuke fee and net amount credited to the test balance.
disputed true once you simulated a dispute on it.
status Sandbox uses completed / open / failed / canceled.

Filter live payments with ?status=succeeded.


Refund — GET /v1/refunds

Sandbox returns real refund rows:

{ "id": "…uuid", "amount_cents": 500, "currency": "eur", "status": "succeeded",
  "reason": "requested_by_customer", "charge_id": "ch_test_…", "payment_intent_id": "pi_test_…",
  "created_at": "2026-08-18T07:02:10Z" }

Live returns the refunded charges (one row per charge, not per refund):

{ "charge_id": "ch_3Nk91x", "amount_refunded": 500, "currency": "eur",
  "refunded": false, "updated_at": "2026-08-18T07:02:10Z" }

refunded: false with a non-zero amount_refunded means a partial refund. amount_refunded is the authoritative running total for that payment.


Dispute — GET /v1/disputes

A chargeback: the disputed amount is held and there is a hard deadline.

Live

{ "dispute_id": "dp_7c", "charge_id": "ch_3Nk91x", "amount": 2500, "currency": "eur",
  "status": "needs_response", "reason": "fraudulent", "evidence_due_by": "2026-08-31T23:59:59Z",
  "has_evidence": false, "past_due": false, "dispute_created_at": "2026-08-19T11:15:00Z" }
Field Meaning
status needs_response (act now) → under_reviewwon (funds returned) or lost (funds gone plus a fee).
reason Why the bank reversed it — drives which evidence helps.
evidence_due_by Hard deadline. Missing it is an automatic loss. Surface it prominently.
has_evidence Whether evidence was already submitted.
past_due true once the deadline passed.

Sandbox adds id, session_id, amount_cents, fee_cents, evidence_submitted_at, closed_at, created_at.


Payout — GET /v1/payouts

A transfer of the merchant's balance to their bank. It bundles many payments — never 1:1 with a payment. Requires the restricted payouts:read scope.

{ "payout_id": "po_44", "amount": 128400, "currency": "eur", "status": "paid",
  "method": "standard", "arrival_date": "2026-08-21",
  "destination_bank_name": "Commerzbank", "destination_last4": "4021",
  "payout_created_at": "2026-08-19T02:00:00Z" }
Field Meaning
amount Net amount sent to the bank, after fees, refunds and reserves.
status pendingin_transitpaid, or failed (returned to the balance).
arrival_date Expected bank value date (date only).
destination_* Which bank account received it.

Sandbox rows use id, amount_cents, created_at plus failure_code / failure_message.


Balance — GET /v1/balance

Money currently held for the merchant. Same scope as payouts.

{ "object": "balance", "livemode": false,
  "available": [{ "amount": 240000, "currency": "eur" }],
  "pending":   [{ "amount":  35000, "currency": "eur" }] }
Field Meaning
available Matured funds that can be paid out.
pending Funds still inside the settlement delay (T+2 in sandbox).

Live balances are served as empty arrays unless the installation has settlement access.


Product — GET /v1/products, /v1/products/{id}

{ "object": "product", "id": "…uuid", "name": "Pro plan", "description": "…", "active": true,
  "images": ["https://…"], "tags": ["saas"], "unit_label": "seat", "url": "https://…",
  "created_at": "…", "updated_at": "…" }
Field Meaning
active false = archived. Hide from new purchases, keep for history.
unit_label What one unit is called on the checkout page.
images / tags / url Listing data maintained by the merchant.

Prices are not embedded — they belong to the merchant's price list and are not exposed in v1.


Invoice — GET /v1/invoices, /v1/invoices/{id}

Live only; sandbox returns an empty list.

{ "object": "invoice", "id": "…uuid", "invoice_number": "2026-0301", "status": "paid",
  "currency": "eur", "subtotal_amount": 1900, "tax_amount": 361, "total_amount": 2261,
  "customer_name": "Ada GmbH", "customer_email": "ops@ada.de", "customer_type": "business",
  "issue_date": "2026-08-01", "due_date": "2026-09-01", "paid_at": "2026-08-04T10:00:00Z",
  "created_at": "2026-08-01T09:00:00Z" }
Field Meaning
invoice_number Legally sequential number. Show this to humans, use id in code.
subtotal_amount / tax_amount / total_amount Net, tax and gross, minor units.
customer_type individual or business — drives tax treatment.
status draft (not sent) · open (sent, unpaid) · paid · void (cancelled) · uncollectible (written off).
issue_date / due_date Dates only.
paid_at null until settled.

Subscription — GET /v1/subscriptions

{ "id": "…uuid", "status": "active", "currency": "eur", "amount_minor": 1900, "quantity": 1,
  "customer_email": "ada@example.com", "customer_name": "Ada Lovelace",
  "current_period_start": "2026-08-17T09:41:12Z", "current_period_end": "2026-09-17T09:41:12Z",
  "cancel_at_period_end": false, "created_at": "2026-05-02T08:00:00Z" }
Field Meaning
amount_minor × quantity What is billed each period.
current_period_end When the next invoice is generated — use it as the access expiry date.
cancel_at_period_end true = still active now, ends at current_period_end. Don't revoke access yet.
status trialing / active → grant access. past_due → dunning, grace period is your call. canceled / unpaid → revoke.

Analytics summary — GET /v1/analytics/summary?days=30

Pre-aggregated volume; far cheaper than paging every payment. days is clamped to 1–365 (default 30).

{ "object": "analytics_summary", "livemode": true, "period_days": 30, "payment_count": 512,
  "gross_amount": 1284000, "refunded_amount": 21500, "net_amount": 1262500, "currency": "eur" }
Field Meaning
period_days Length of the window, ending now.
payment_count Number of succeeded payments in the window.
gross_amount Sum of succeeded payments.
refunded_amount Refunded amount in the window.
net_amount gross_amount - refunded_amount. Processor fees are not deducted here.
currency Currency of the first row; null when there was no volume.

Aggregation reads at most the 1 000 most recent payments of the window.


List envelope

Every list endpoint wraps results the same way:

{ "object": "list", "data": [ … ], "has_more": true }
Field Meaning
data The array of objects for this page.
has_more true = keep paging with a higher offset. There is no total count and no cursor.

Error envelope

{ "error": { "type": "insufficient_scope", "message": "Missing required scope: payments:read" } }

Rate limits & errors

Four enforcement levels, and how to stay under them.

Limits are enforced hierarchically — burst, then installation, then app, then platform. Test and live traffic are counted separately, so sandbox load can never eat live quota.

Per-installation quotas

Group Test Live
Read 120 req/min, burst 10/s 600 req/min, burst 30/s
Write 60 req/min, burst 5/s 300 req/min, burst 15/s
Heavy (analytics, exports) 20 req/min, burst 2/s 60 req/min, burst 5/s

App-wide ceilings

Across all installations of one app: 3 000 req/min in test, 30 000 req/min in live. A generous platform ceiling sits above that as an emergency brake.

Headers

Every response carries:

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 583
X-RateLimit-Reset: 27
Retry-After: 27      # only on 429

Handling 429

async function call(url, token, attempt = 0) {
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (res.status === 429 && attempt < 5) {
    const wait = Number(res.headers.get("Retry-After") ?? 1) * 1000;
    await new Promise((r) => setTimeout(r, wait + Math.random() * 250));
    return call(url, token, attempt + 1);
  }
  return res;
}

Add jitter, cap retries, and never retry a 403.

Staying efficient

  • React to webhooks instead of polling.
  • Cache /v1/merchant; it changes rarely.
  • Use time-window filters for backfills, and run them off-peak.
  • When usage crosses 80 % of an app quota we notify you before throttling bites.