REST API

All server-side endpoints for sessions, refunds and invoices.

Error reference

Every error type the API can return, with meaning and how to react.

Error reference

All errors are JSON with the same envelope:

{
  "error": {
    "type": "invalid_request",
    "code": "customer_email_invalid",
    "message": "customer_email must be a valid email address",
    "param": "customer_email",
    "request_id": "req_01H…"
  }
}
  • type — high-level family (see table below).
  • code — machine-readable specific error. Always populated for validation and idempotency errors.
  • message — human-readable. Safe to log, do not show verbatim to end customers.
  • param — the offending body field, when applicable.
  • request_id — always log it. Support can look it up instantly.

HTTP status codes

HTTP Meaning Retry?
200 / 201 Success
400 Client error (bad input) No — fix and resend
401 Auth failed (missing / bad / revoked key) No
402 Payment declined at the network Depends (see decline_code)
403 Key valid but not allowed for this action No
404 Resource not found No
409 Conflict (idempotency reuse, merchant not ready) No — see code
422 Semantically invalid (e.g. session already paid) No
429 Rate limited Yes — respect Retry-After
5xx spuke server issue Yes — with the same Idempotency-Key

error.type values

Type HTTP When
invalid_request 400 Missing / malformed field. param tells you which.
amount_too_small 400 Below the 2.50 EUR equivalent minimum.
amount_too_large 400 Above the per-charge maximum.
currency_unsupported 400 Currency not in the supported list.
authentication_error 401 Key missing, malformed, revoked or wrong mode (test vs live).
permission_error 403 Key valid but scope/role forbids this action.
card_error 402 Buyer's card was declined. See code / decline_code.
idempotency_error 409 Same Idempotency-Key reused with a different request body. code = idempotency_key_reused.
resource_conflict 409 e.g. merchant_not_ready, subscription_already_canceled.
not_found 404 The id does not exist under this merchant.
invalid_state 422 session_expired, session_already_completed, charge_disputed.
rate_limit_error 429 Too many requests. Back off.
api_error 5xx Transient spuke error. Retry with the same Idempotency-Key.

Idempotency conflict — exact shape

Reusing an Idempotency-Key with a body that differs from the original request always returns:

{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency-Key reused with a different request body"
  }
}
  • HTTP status: 409 Conflict
  • Uniqueness scope: (merchant_id, method, path, Idempotency-Key).
  • Same key + same body → replays the original response (status + body) verbatim.
  • Same key + different body → the 409 shown above.
  • Keys expire after 24 hours.

Recommended handling

  • Never retry 4xx errors except 409 idempotency_error after you've fixed the body and rotated the key.
  • Always retry 5xx with the same idempotency key, using exponential backoff (1s → 2s → 4s, max 5 attempts).
  • On 429, sleep for the number of seconds in Retry-After (default 1) before the next attempt.
  • Show error.message to internal operators, not to end customers — use a friendly wrapper.

The client_secret

What it is, how long it lives, and how to use it safely.

The client_secret

Each Checkout Session returns a client_secret alongside its id:

{
  "id": "cs_01H…",
  "client_secret": "cs_01H…_secret_a7f9…",
  "checkout_url": "https://payments.spuke.com/c/cs_01H…?cs=cs_01H…_secret_a7f9…",
  "embed_url":    "https://payments.spuke.com/embed/cs_01H…?cs=cs_01H…_secret_a7f9…"
}

What it does

The client_secret authorises one specific browser session to load the hosted checkout for that session. It is scoped to a single cs_… id and cannot be used to create, modify, refund, or list anything.

It is safe to send to the browser (embed page, redirect URL, iframe src). It is not safe to log publicly or share across users — anyone with the value can open that particular checkout.

Lifetime

Event Effect on client_secret
Session created Valid for 24 hours or until session status changes.
Session complete / expired / canceled Immediately invalid — loading the checkout returns session_expired.
Session paid via async method (SEPA, Klarna) Immediately invalid; buyer is redirected to success_url.

There is no way to renew a client_secret. If it expires, create a new Checkout Session and redirect to the new checkout_url.

Correct usage

Backend (your server):

const session = await fetch("https://api.spuke.com/v1/checkout/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPUKE_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `order_${orderId}`,
  },
  body: JSON.stringify({ amount: 4990, currency: "eur", customer_name, customer_email }),
}).then(r => r.json());

// Redirect the buyer:
res.redirect(303, session.checkout_url);

// OR return only what the browser needs:
res.json({ embed_url: session.embed_url });

Frontend:

<iframe src="{embed_url}" allow="payment" width="100%" height="720"></iframe>

Never send sk_live_… or sk_test_… to the browser. The browser only ever sees client_secret / checkout_url / embed_url.

Security notes

  • Treat the client_secret like a one-time link: don't email it, don't index it, don't put it in shared logs.
  • If a buyer abandons a session, you can call POST /v1/checkout/sessions/{id}/expire to invalidate its client_secret early.

Idempotency

Safely retry any POST request

Idempotency

Every POST endpoint in the spuke API accepts an Idempotency-Key header. Use it to safely retry a request after a network error without creating duplicate resources.

How it works

The uniqueness scope is (merchant_id, method, path, Idempotency-Key). When the API receives a POST with an Idempotency-Key:

  1. If the key has never been used on this endpoint → the request is processed normally, and its response (status + body) is stored for 24 hours.
  2. If the key was already used with the same request body → the original response is returned verbatim. The endpoint is not re-executed.
  3. If the key was already used with a different request body → the API returns 409 Conflict:
{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency-Key reused with a different request body"
  }
}

Keys expire after 24 hours. After that, the same key can be reused for a new request.

Choosing a good key

  • Deterministic per business action: order_12345, sub_2026-07-18_001, refund_ch_abc_partial_1.
  • Do not use timestamps or random UUIDs generated per retry — the whole point is that a retry uses the same key.
  • 1–255 characters, ASCII.

Endpoints that support it

Method Path
POST /v1/checkout/sessions
POST /v1/checkout/sessions/{id}/expire
POST /v1/subscriptions
POST /v1/subscriptions/{id}/cancel
POST /v1/products
POST /v1/products/{id}
POST /v1/products/{id}/prices
POST /v1/products/{id}/default_price

GET and DELETE requests ignore the header — they are already idempotent by definition.

Retries

Combine Idempotency-Key with exponential backoff for 5xx and 429 responses:

async function postWithRetry(url: string, body: unknown, key: string) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SPUKE_SECRET_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify(body),
    });
    if (res.status < 500 && res.status !== 429) return res;
    const wait = res.headers.get("Retry-After");
    await new Promise(r => setTimeout(r, (wait ? +wait : 2 ** attempt) * 1000));
  }
  throw new Error("spuke API unavailable");
}

Never retry a 4xx other than 429 — fix the request first.

Checkout Sessions

Create one-off payments programmatically — the only public payments endpoint today.

Checkout Sessions

A Checkout Session is the primary API endpoint for taking a payment. You create it from your server, redirect the customer to checkout_url, and receive a webhook when it's paid.

Base URL: https://api.spuke.com/v1 Auth: Authorization: Bearer sk_live_… (or sk_test_…)

Create a session

POST /v1/checkout/sessions

curl -X POST https://api.spuke.com/v1/checkout/sessions \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_12345" \
  -d '{
    "amount": 4990,
    "currency": "eur",
    "customer_name":  "Jane Doe",
    "customer_email": "jane@example.com",
    "reference": "ORDER-12345",
    "description": "Order #12345",
    "line_items": [
      { "name": "Sneaker Runner Pro", "quantity": 1, "amount": 4990, "image_url": "https://…/sneaker.jpg", "price_id": "price_1Tu…" }
    ],
    "success_url": "https://shop.example.com/thanks?o=12345",
    "cancel_url":  "https://shop.example.com/cart"
  }'

Body

Field Type Required Notes
amount integer Minor units (cents). Minimum 250 (= 2.50 in the currency).
currency string 3-letter ISO, lowercase.
customer_name string 2–120 chars. Needed to attribute the payment.
customer_email string Valid email. Receipt is sent here.
mode string payment (default) or subscription. For subscription, pass line_items[0].price_id pointing at a recurring price and omit amount.
reference string Your order ID. Echoed on receipts and webhooks.
description string Short description shown on the checkout.
line_items array Display only. Fields: name, quantity, amount, image_url, price_id, product_id.
success_url / cancel_url string Where the browser is sent after the session ends.
metadata object Free-form key/value returned in webhooks.

line_items[].price_id is optional — pass it when the item comes from a product you created in the dashboard, so it appears in receipts and analytics with its catalog link. The actual amount charged is amount — the price is not fetched from the catalog.

Response

{
  "id": "cs_01H…",
  "object": "checkout.session",
  "mode": "live",
  "status": "open",
  "amount": 4990,
  "currency": "EUR",
  "client_secret": "cs_01H…_secret_…",
  "checkout_url": "https://payments.spuke.com/c/cs_01H…?cs=…",
  "embed_url":    "https://payments.spuke.com/embed/cs_01H…?cs=…",
  "expires_at": 1735776000,
  "created": 1735689600
}

Redirect the buyer to checkout_url, or mount embed_url in an iframe.

Retrieve & expire

GET  /v1/checkout/sessions/{id}
POST /v1/checkout/sessions/{id}/expire

Sessions expire automatically 24 hours after creation. An expired session emits checkout.session.expired.

Idempotency

Send Idempotency-Key: <your-key> on every POST. Keys are scoped to (merchant_id, method, path) and expire after 24 hours.

  • Same key + same body → the original response is replayed verbatim (same status, same body).
  • Same key + different body409 Conflict with:
{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency-Key reused with a different request body"
  }
}

See Error reference for the full model.

Fees

Fees are calculated in EUR on your active plan and deducted from the merchant's balance in the transaction currency (converted at live FX). See Fees, currency & minimum amounts.

Errors

HTTP error.type error.code Meaning
400 invalid_request field-specific Missing / malformed field.
400 amount_too_small amount_below_minimum amount below the 2.50 minimum.
401 authentication_error invalid_api_key Bad, revoked or wrong-mode key.
402 card_error card_declined Buyer's card was declined.
409 idempotency_error idempotency_key_reused Same key sent with a different body.
409 resource_conflict merchant_not_ready Merchant onboarding incomplete.
429 rate_limit_error rate_limited Slow down. Respect Retry-After.

Payments

The Payment object returned by the API and delivered on webhooks.

Payments

A Payment represents a single money movement from a customer to a merchant. It is created automatically when a Checkout Session completes or an Invoice is paid — you don't create it directly.

The Payment object

{
  "id": "py_01H…",
  "object": "payment",
  "session_id": "cs_01H…",
  "invoice_id": null,
  "amount": 4990,
  "amount_refunded": 0,
  "currency": "EUR",
  "status": "succeeded",
  "payment_method": "visa_mc",
  "card": { "brand": "visa", "last4": "4242", "country": "DE", "funding": "credit" },
  "fee": {
    "spuke_fee_eur": 132,
    "spuke_fee_charge_currency": 132,
    "psp_fee_eur": 189,
    "currency": "EUR"
  },
  "customer": { "name": "Jane Doe", "email": "jane@example.com" },
  "reference": "ORDER-12345",
  "metadata": {},
  "created": 1735689600
}

Status values

Status Meaning
succeeded Money captured, fee deducted.
processing Async method (SEPA, Klarna) still clearing.
refunded Fully refunded.
partially_refunded Partial refund issued.
disputed Chargeback opened.
failed Attempt failed.

Retrieval

Fetch the parent session/invoice — the payment is embedded in its response:

GET /v1/checkout/sessions/{id}

A dedicated GET /v1/payments/{id} endpoint is planned for the next API release.

Currency & fees

Every fee is computed in EUR against your plan's rate for the actual card region and payment method, then converted back to the charge currency. See Fees, currency & minimum amounts.

Refunds

Refund a full or partial charge — from the dashboard today, via API soon.

Refunds

Refunds are issued today through the Dashboard → Transactions → Refund action. A public POST /v1/refunds endpoint is on the roadmap.

How it works

  1. Merchant clicks Refund on a succeeded transaction and enters an amount (full or partial).
  2. spuke issues a reversal of the destination transfer on the platform account so the money comes out of the merchant's balance, not spuke's.
  3. spuke also reverses the pro-rata application fee — you only keep fees for what the buyer actually paid.
  4. Transaction status becomes refunded (full) or partially_refunded (partial).
  5. A charge.refunded webhook fires.

Rules

  • You cannot refund a disputed charge — resolve the dispute first. The dashboard shows a friendly error (charge_disputed).
  • Refunds can be issued for up to 180 days after the original charge (processor limit).
  • Partial refunds can be issued multiple times up to the total captured amount.
  • Fee reversal is automatic and visible in the merchant's Wallet as spuke fee refund.

Webhook payload (charge.refunded)

{
  "type": "charge.refunded",
  "data": {
    "charge_id": "ch_…",
    "session_id": "cs_…",
    "amount_refunded": 1990,
    "amount": 4990,
    "currency": "EUR",
    "reason": "requested_by_customer",
    "fully_refunded": false
  }
}

Invoices

How invoices work in spuke, how customers pay them, and the roadmap for the invoice API.

Invoices

Invoices in spuke are created in the merchant dashboard (/dashboard/invoices/new). Each invoice generates a public payment link that anyone with the link can pay — no API key needed on the customer side.

A public REST API for create / list / send invoice is on the roadmap. Today you can automate invoice payments through Checkout Sessions and use the dashboard for invoicing.

Anatomy

Field Description
number Auto-generated SPK-INV-YYYY-NNNN.
currency ISO code — locked to the currency of any product line-item you add.
line_items Free-form or imported from your Products catalog. Product-sourced items are locked to spuke values (price, quantity, VAT, currency).
total_amount Sum incl. VAT. Minimum 2.50 in invoice currency (below that, PSP + spuke fees can't be covered).
status draftopenpaid / void / uncollectible.
public_token Random token used in the customer-facing URL.

Customer payment flow

  1. Merchant creates the invoice and clicks Send — status becomes open, public_token is minted.
  2. Customer opens https://payments.spuke.com/i/{public_token} (link is emailed automatically).
  3. The page calls the internal function invoice-checkout with { action: "create_pi", token }, which:
    • creates a PaymentIntent as a destination charge on the merchant's connected account,
    • resolves enabled payment methods for the invoice currency (Klarna, iDEAL, Bancontact, etc. are filtered per currency),
    • applies the correct application-fee amount via the shared fee engine.
  4. On success the invoice is marked paid, a receipt is emailed and a invoice.paid webhook is delivered to your endpoints.

Adding products to an invoice

In the New invoice page, click Add from products → pick a product. spuke will:

  • copy name, description, image, price, currency, VAT from your catalog;
  • lock those fields (marked "From product · locked to spuke values");
  • lock the invoice currency to the product currency;
  • disable recurring products (subscriptions are API-only, coming soon).

Webhooks

Configure your endpoints in Dashboard → Developers → Webhooks.

Event When
invoice.finalized Invoice moves from draft to open (send).
invoice.paid Customer paid successfully.
invoice.payment_failed Payment attempt failed.
invoice.voided Merchant voided an open invoice.

Payload includes id, number, amount_due, amount_paid, currency, customer_email, customer_name, line_items[], metadata.

Fees & minimums

All fees are calculated in EUR (see Fees, currency & minimum amounts). Minimum invoice total is 2.50 in the invoice currency.

Subscriptions

Recurring billing on your spuke account

Subscriptions

Create recurring subscriptions for customers using a recurring price from your catalog. Subscriptions run on your connected spuke account; spuke deducts the platform fee automatically from each renewal invoice.

Base URL: https://api.spuke.com/v1 Auth: Authorization: Bearer sk_live_… (or sk_test_…)

Prerequisites

  1. Create a product and a recurring price (see Products and Prices).
  2. The price must have recurring.interval set (day, week, month, year).

Create a subscription

POST /v1/subscriptions

curl -X POST https://api.spuke.com/v1/subscriptions \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sub_20260718_001" \
  -d '{
    "price_id": "price_1Tu…",
    "customer_name":  "Jane Doe",
    "customer_email": "jane@example.com",
    "quantity": 1,
    "trial_days": 14,
    "metadata": { "plan": "pro" }
  }'

Body

Field Type Required Notes
price_id string Recurring price from your catalog.
customer_name string 2–120 chars.
customer_email string Valid email. Receipts and dunning are sent here.
quantity integer Defaults to 1.
trial_days integer Free trial before first charge.
metadata object Free-form key/value returned in webhooks.

Response

{
  "id": "sub_1Tu…",
  "object": "subscription",
  "status": "incomplete",
  "customer": "cus_1Tu…",
  "price_id": "price_1Tu…",
  "quantity": 1,
  "currency": "EUR",
  "amount": 1990,
  "current_period_start": 1735689600,
  "current_period_end":   1738368000,
  "latest_invoice": "in_1Tu…",
  "client_secret": "pi_1Tu…_secret_…",
  "trial_end": null,
  "created": 1735689600
}

The subscription is created in incomplete state. Use the returned client_secret on the customer's browser to confirm the first payment. Once confirmed the status transitions to active (or trialing if trial_days was set) and a customer.subscription.updated webhook is fired.

Retrieve

GET /v1/subscriptions/{id}

List

GET /v1/subscriptions?limit=20

Returns up to 100 subscriptions ordered by creation date (newest first).

Cancel

POST /v1/subscriptions/{id}/cancel

curl -X POST https://api.spuke.com/v1/subscriptions/sub_1Tu…/cancel \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cancel_sub_1Tu_20260718" \
  -d '{ "at_period_end": true }'
Field Default Meaning
at_period_end true Keep active until the current period ends, then cancel.
at_period_end: false Cancel immediately. No further invoices.

Status model

Status Meaning
incomplete Waiting for the first payment confirmation.
incomplete_expired First payment was not confirmed within 23 h.
trialing Free trial in progress.
active Paid and current.
past_due Renewal failed. Dunning in progress.
unpaid All retries exhausted, subscription frozen.
canceled Terminated.

Fees

Recurring invoices carry a platform fee computed from your plan and payment method. Fees are settled in EUR and deducted per invoice — same rules as one-off Checkout Sessions. See Fees, currency & minimum amounts.

Idempotency

Send Idempotency-Key: <your-key> on POST /v1/subscriptions and POST /v1/subscriptions/{id}/cancel. Keys are scoped to (merchant_id, method, path) and expire after 24 hours.

  • Same key + same body → original response replayed verbatim.
  • Same key + different body409 Conflict with type: "idempotency_error", code: "idempotency_key_reused". See Error reference.

Errors

HTTP error.type error.code Meaning
400 invalid_request field-specific Missing / malformed field, or price is not recurring.
401 authentication_error invalid_api_key Bad, revoked or wrong-mode key.
404 not_found price_not_found / subscription_not_found Not owned by your account.
409 idempotency_error idempotency_key_reused Same key with different body.
409 resource_conflict merchant_not_ready / subscription_already_canceled See message.
429 rate_limit_error rate_limited Respect Retry-After.

Webhooks

Listen for these events (see Event reference for payloads):

  • customer.subscription.created / updated / deleted
  • customer.subscription.trial_will_end — fires 3 days before the trial ends
  • invoice.payment_succeeded — renewal charged
  • invoice.payment_failed — dunning stage advanced (notice → warning → final)

Products

Create products in the dashboard or via API — they live directly on your spuke account.

Products

A Product describes something you sell — a shoe, a subscription tier, a service. Products in spuke are created directly on your spuke account — either via the dashboard (Dashboard → Products → New) or via the public API described below.

Fields

Field Description
name Public name shown on receipts, invoices and checkout.
description Long description.
images[] Up to 8 image URLs. First image is used as the thumbnail.
tax_code Optional spuke tax code (txcd_…).
unit_label e.g. "seat", "month".
statement_descriptor What appears on the buyer's card statement (≤ 22 chars).
url Link back to your product page.
shippable Physical goods = true.
package_dimensions Length / width / height (cm) and weight (g).
metadata Free-form key/value.
tags[] spuke-internal — used for catalog auto-grouping.
default_price The Price object linked as the default.

Minimum price

Every unit_amount must be ≥ 250 minor units (2.50) in the price's currency. Smaller amounts don't cover PSP + spuke fees.


API — Create a product

POST https://api.spuke.com/v1/products

Authenticate with a secret API key (sk_live_… / sk_test_…) — scope products:write. Pass an optional Idempotency-Key header to make retries safe.

curl https://api.spuke.com/v1/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: prod-launch-sneaker-v1" \
  -d '{
    "name": "Runner 01",
    "description": "Lightweight everyday shoe",
    "images": ["https://cdn.example.com/runner-01.jpg"],
    "shippable": true,
    "metadata": { "sku": "RUN-01" },
    "tags": ["shoes", "new"],
    "price": {
      "currency": "eur",
      "unit_amount": 8900
    }
  }'

Pass a price object to create the Product and its default Price in one call. Omit it if you want to add prices later.

One-time vs recurring prices

A Product itself is just the catalog item. Whether it is charged once or repeatedly is controlled by the attached Price:

  • One-time paid product: send price.currency + price.unit_amount only.
  • Recurring paid product: send the same price fields plus price.recurring.

Recurring price fields:

Field Description
price.recurring.interval Required for recurring prices. Allowed: day, week, month, year.
price.recurring.interval_count Optional. Defaults to 1. Example: 3 + month = every 3 months.
price.recurring.trial_period_days Optional trial length in days.
price.recurring.usage_type Optional: licensed or metered.

Example — monthly recurring product:

curl https://api.spuke.com/v1/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: prod-pro-plan-monthly-v1" \
  -d '{
    "name": "Pro Plan",
    "description": "Monthly access to Pro features",
    "metadata": { "sku": "PRO-MONTHLY" },
    "tags": ["subscription", "pro"],
    "price": {
      "currency": "eur",
      "unit_amount": 2900,
      "recurring": {
        "interval": "month",
        "interval_count": 1,
        "trial_period_days": 14
      }
    }
  }'

The response will include a recurring Price with type: "recurring" and the recurring interval fields.

Response

{
  "id": "prod_ABC123",
  "object": "product",
  "name": "Runner 01",
  "active": true,
  "default_price": "price_XYZ789",
  "prices": [{ "id": "price_XYZ789", "currency": "eur", "unit_amount": 8900, ... }],
  "metadata": { "sku": "RUN-01" },
  "tags": ["shoes", "new"]
}

The returned prod_… and price_… IDs are what you use everywhere else — catalog management and invoice product selection. Recurring prices define the billing terms, but automatic subscription billing requires a recurring-billing flow.


API — Other product operations

Method Path Description
GET /v1/products List products (params: limit, starting_after). Scope products:read.
GET /v1/products/{id} Retrieve a product with all its prices.
POST /v1/products/{id} Update fields (name, description, images, active, metadata, tags, …).
DELETE /v1/products/{id} Archive the product and all its prices (active=false).
POST /v1/products/{id}/default_price Body { "price": "price_…" } — change the default price.

API — Prices on a product

Method Path Description
POST /v1/products/{id}/prices Create a new Price on this product. Fields: currency, unit_amount, nickname, recurring, tax_behavior, lookup_key, set_as_default, metadata.
GET /v1/products/{id}/prices List all prices on this product.

Prices are immutable except for active, nickname, tax_behavior, lookup_key, metadata. To change amount or currency, create a new Price and mark the old one inactive.

curl https://api.spuke.com/v1/products/prod_ABC123/prices \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "eur",
    "unit_amount": 9900,
    "nickname": "2026 launch price",
    "set_as_default": true
  }'

Using a product

  • In an invoice: New invoice → Add from products → the item is locked to the product's spuke values.
  • In a Checkout Session: pass line_items[].price_id: "price_…" so receipts and analytics link back to the catalog entry.

Product ID vs Price ID

ID What it is When you use it
prod_… The container — name, description, images. Reporting, catalog management.
price_… The billable price — amount, currency, one-time or recurring. Every transaction and invoice line-item.

Rule of thumb: to charge someone you always need a price (or an ad-hoc amount), never just a product.

Idempotency

All POST endpoints (/v1/products, /v1/products/{id}, /v1/products/{id}/prices, /v1/products/{id}/default_price) accept Idempotency-Key. Same key + same body replays the original response; same key + different body returns 409 Conflict with type: "idempotency_error", code: "idempotency_key_reused". See Error reference.

Prices

One-time and recurring prices for your products.

Prices

A Price represents how much and how often a product costs. Every product needs at least one price to be sellable.

Types

Type recurring Use for
one_time null Physical goods, single services, invoices.
recurring { interval, interval_count } Subscriptions (API-only, coming soon).

Fields

Field Description
unit_amount Amount in minor units (cents). Must be ≥ 250 (= 2.50).
currency 3-letter ISO code, lowercase.
product The parent prod_… ID.
type one_time or recurring.
recurring.interval day, week, month, year.
recurring.interval_count e.g. 3 → every 3 months.
nickname Internal label (e.g. "Pro monthly EUR").
active false archives it — existing subscriptions keep billing.

Currency lock

Once a price exists, its currency is immutable (spuke rule). To sell in another currency, create a second price for the same product with a different currency.

Recurring prices in invoices

Recurring products are disabled in the invoice UI — invoices are one-shot documents. To bill on a schedule, use the upcoming Subscriptions API (/v1/subscriptions, currently in design).

Creating a price

Prices are created automatically when you create a product from the dashboard. To add additional prices to an existing product, use Dashboard → Products → {product} → Add price (coming soon) or call spuke directly on your connected account.

Finding your Price ID

Dashboard → Products — the list shows both the Product ID (prod_…) and the Price ID (price_…) with copy buttons.

Catalog: categories & tags

Organise your product catalog with tag-based auto-grouping.

Catalog: categories & tags

spuke adds a lightweight catalog layer on top of spuke Products so you can group and filter products without touching spuke metadata.

Concepts

Concept Where it lives Purpose
Tag merchant_products.tags[] (spuke DB) Free-form label, e.g. blue, winter, bestseller.
Category merchant_product_categories (spuke DB) Named bucket with a single tag. Every product carrying that tag is auto-listed under the category.

Example: create a category Shoes with tag = blue. Every product with blue in its tags[] is now listed under Shoes — no manual assignment.

Managing

  • Dashboard → Products → Categories — full CRUD for categories.
  • Dashboard → Products → {product} → Tags — edit the product's tags.

Filtering

The products list has a category dropdown that filters by the category's tag. Pagination kicks in at 20 products.

API

Not exposed publicly. All catalog operations go through the dashboard function merchant-products (list_categories, create_category, update_category, delete_category, update_product_tags).