Veracity Pay

A payment service for testing. It looks and behaves like a real PSP – hosted checkout, cards, Swish, invoices, subscriptions, webhooks – but no money ever moves. Each payment is approved with a six-digit code sent to the payer's email (the Test inbox).

Overview

  • One engine, several flavours. The Stripe flavour under /v1 is complete. Nets Easy (/nets/v1), Adyen (/adyen/v71) and Kustom (/kustom) are thinner adapters for each provider's hosted checkout flow.
  • Hosted checkout at /pay/<cs_test_…> with the merchant's name, logo and colour. Payers are recognised across merchants (cookie + email) and get their details prefilled.
  • Amounts are in the smallest currency unit (öre), and currencies are lowercase ISO codes (sek). Prices include VAT; the VAT is calculated using the merchant's default rate (25% unless set otherwise).
  • Test mode everywhere. All keys are named vp_sk_test_…/vp_pk_test_… and every object has livemode: false.

Built for AI-driven development

The fastest way is to let your coding agent do the work through our MCP server. The agent asks which payment partners to emulate and which e-mail the account should have, registers it and writes the integration. The keys arrive in your inbox – the agent never sees a secret.

1. Add the MCP server

Claude Code:

claude mcp add --transport http veracity-pay https://pay.veracity.se/mcp

Cursor, Windsurf and other clients with Streamable HTTP (.mcp.json or similar):

{
  "mcpServers": {
    "veracity-pay": {
      "url": "https://pay.veracity.se/mcp",
      "headers": { "Authorization": "Bearer ${VERACITY_PAY_SECRET_KEY}" }
    }
  }
}

2. Ask the agent

I want us to use Veracity Pay to emulate payments in this project.

The agent calls list_providers, asks you two things and calls register_merchant. You get an e-mail with the merchant ID (MID), keys, webhook secret and ready-to-run commands (.env.local, vercel env add and the webhook step). The agent ends with: “You have received an e-mail with your API keys and the commands to add them. I see no secrets.”

3. Give the test tools your key

Once the key is in your environment, the MCP client sends it in an Authorization header. Then complete_checkout, set_next_charge_outcome, advance_clock, key_status and extend_key work – without the key showing in the chat.

claude mcp add --transport http veracity-pay https://pay.veracity.se/mcp --header "Authorization: Bearer ${VERACITY_PAY_SECRET_KEY}"

4. Add to CLAUDE.md or AGENTS.md

## Payments (Veracity Pay, test only)

- Payments are emulated with Veracity Pay (https://pay.veracity.se), a test payment provider. No real money moves.
- Use the MCP server `veracity-pay` (https://pay.veracity.se/mcp) for guides and test helpers. Never print or log secret keys; read them from env.
- Env: VERACITY_PAY_BASE_URL, VERACITY_PAY_SECRET_KEY, NEXT_PUBLIC_VERACITY_PAY_PUBLISHABLE_KEY, VERACITY_PAY_WEBHOOK_SECRET.
- Verify webhooks with the `Veracity-Signature` header (HMAC-SHA256 of `${t}.${rawBody}`); handle events idempotently on event.id.
- Keys are valid one month at a time. A 401 with code `api_key_expired` means: ask the developer to extend the key (dashboard or MCP tool `extend_key`).
- Full reference: https://pay.veracity.se/llms-full.txt

For agents without MCP

Keys are valid one month at a time

Every key has an expiry date. Five days before, an e-mail arrives with a link that extends it by one month from today; the same button is in the dashboard and as the extend_key tool. An expired key gets 401 with the code api_key_expired. Extending keeps the same key, and webhook secrets never expire.

Getting started

  1. Create a test account (free for now): enter your name and email – and a company if you want one shown to payers – then confirm the email with the code (it lands in the Test inbox). The account immediately gets a merchant ID (MID), an account ID (acct_…) and a secret and a publishable test key. You can create more merchant accounts from the Dashboard.
  2. Set the name, logo, colour, VAT rate and payment methods under Settings. Keys can be rolled (the old ones are revoked immediately).
  3. Add a webhook endpoint under Webhooks and copy the signing secret (whsec_…).
  4. Set the environment variables in your app:
VERACITY_PAY_BASE_URL=http://localhost:3100        # or your deployed URL
VERACITY_PAY_SECRET_KEY=vp_sk_test_…
NEXT_PUBLIC_VERACITY_PAY_PUBLISHABLE_KEY=vp_pk_test_…
VERACITY_PAY_WEBHOOK_SECRET=whsec_…

Locally, you can also create a merchant without the Dashboard: POST /api/dev/seed with {"email":"du@exempel.se","name":"Min app"} (disabled in production).

The merchant dashboard

The Dashboard lists payments with filters (status, payment method, date) and a detail page for each payment with a timeline, decline reason, customer and webhook deliveries. It also covers refunds (full or partial, with a reason), customers, subscriptions and invoices with cancellation, products and prices, the event log, webhooks with “Resend”, and a simulated balance and payouts view.

API basics

Authentication

Send your secret key as Authorization: Bearer vp_sk_test_…. HTTP Basic with the key as the username (curl -u vp_sk_test_…:) also works. Publishable keys are rejected with a 401.

Request and response format

Requests accept both JSON and Stripe's bracketed form encoding (line_items[0][price]=price_…), so both fetch with JSON and Stripe's own client libraries work. Responses are JSON with an object field, ID prefixes (cs_test_, pi_test_, cus_, sub_, in_, price_, prod_, evt_) and Unix timestamps in seconds.

Idempotency

Send Idempotency-Key: <unique string> on POST requests. The same key with the same parameters returns the original response again (with the header Idempotent-Replayed: true); the same key with different parameters returns 400 idempotency_error. Keys are scoped to the merchant.

Errors

HTTP 402
{
  "error": {
    "type": "card_error",              // invalid_request_error | authentication_error | idempotency_error | api_error
    "code": "card_declined",
    "decline_code": "insufficient_funds",
    "message": "Your card has insufficient funds.",
    "param": null
  }
}

HTTP 401
{ "error": { "type": "authentication_error", "code": "api_key_expired",
    "message": "This API key expired on 2026-12-07. … extend it …" } }

Lists and pagination

List endpoints return {"object":"list","data":[…],"has_more":true,"url":"/v1/…"}, newest first. Use limit (1–100, default 10), starting_after and ending_before with an object ID.

Checkout Sessions

Create a session on your server and redirect the payer to its url. After a successful payment the payer is sent to success_url, where {CHECKOUT_SESSION_ID} is replaced with the session ID; if the payer cancels, they go to cancel_url. Never rely on the redirect alone – confirm the payment with a webhook or by retrieving the session.

One-time payment

curl http://localhost:3100/v1/checkout/sessions \
  -u "$VERACITY_PAY_SECRET_KEY:" \
  -H "Idempotency-Key: order-1001" \
  -d mode=payment \
  -d "line_items[0][price_data][currency]=sek" \
  -d "line_items[0][price_data][unit_amount]=25000" \
  -d "line_items[0][price_data][product_data][name]=Vägavgift 2027" \
  -d "line_items[0][quantity]=1" \
  -d customer_email=anna@example.com \
  -d client_reference_id=order-1001 \
  -d "success_url=http://localhost:3000/klar?session_id={CHECKOUT_SESSION_ID}" \
  -d cancel_url=http://localhost:3000/kassa

Subscription (monthly or yearly)

POST /v1/checkout/sessions
{
  "mode": "subscription",
  "line_items": [{ "price": "price_…", "quantity": 1 }],     // or a lookup_key as price
  "customer_email": "kassor@granbacken.se",                  // or "customer": "cus_…"
  "client_reference_id": "forening_42",
  "subscription_data": { "metadata": { "association_id": "42", "plan": "bas" } },
  "metadata": { "association_id": "42" },
  "success_url": "https://app.example/betalning/klar?session_id={CHECKOUT_SESSION_ID}",
  "cancel_url": "https://app.example/planer"
}

200 OK
{
  "id": "cs_test_a1B2…",
  "object": "checkout.session",
  "mode": "subscription",
  "status": "open",
  "payment_status": "unpaid",
  "url": "http://localhost:3100/pay/cs_test_a1B2…",
  "amount_total": 9900,
  "currency": "sek",
  "total_details": { "amount_discount": 0, "amount_shipping": 0, "amount_tax": 1980 },
  "customer": null,
  "subscription": null,
  "expires_at": 1794090000,
  …
}

After payment: status: "complete", payment_status: "paid", customer: "cus_…", subscription: "sub_…". Retrieve it with ?expand[]=subscription to get the full object. More parameters: payment_method_types[] (card, swish, invoice, pay_by_bank, apple_pay, google_pay), expires_at (at most 24 h), locale, customer_creation=always. Prices can also be created inline in line_items with price_data[recurring][interval]=month.

Subscriptions

Statuses: incomplete → active → past_due → canceled (plus incomplete_expired). The first invoice has billing_reason: subscription_create, and renewals have subscription_cycle. The billing period is available both on the subscription (current_period_start/end) and on items.data[0].

  • Recurring charges: the first payment is made in Checkout and approved with a code. The payment method is then saved to the customer and the subscription (like Stripe's setup_future_usage=off_session). Every renewal is then charged without a code: an invoice is created and is either paid or fails.
  • Renewal events: invoice.created, invoice.finalized, customer.subscription.updated (new period), payment_intent.created, followed by either payment_intent.succeeded + invoice.paid + invoice.payment_succeeded, or payment_intent.payment_failed + invoice.payment_failed + customer.subscription.updated with status: past_due.
  • Retries: a failed invoice is retried on days 3, 5 and 7 after the start of the period (4 attempts in total). If an attempt succeeds, the subscription becomes active again. If all of them fail, the invoice becomes uncollectible and the subscription canceled (customer.subscription.deleted).
  • Outcome of the next charge: set by the payer in My payments or by the merchant with POST /v1/test_helpers/subscriptions/:id/next_charge_outcome (approve, insufficient_funds, restricted_card, timeout or automatic; always=true makes it persistent). The setting applies to one charge and is then reset. Timeout means the charge stays processing for 30 s and then fails. Without a saved payment method, the charge always fails. The subscription object shows the setting in veracity_next_charge.
  • Cancellation: at the end of the period with POST /v1/subscriptions/:id and cancel_at_period_end=true (undo with false), or immediately with DELETE /v1/subscriptions/:id (or POST /v1/subscriptions/:id/cancel). Both send customer.subscription.deleted when the subscription ends.
  • Time: renewals run in real time (Vercel Cron every minute against /api/cron/tick, protected with CRON_SECRET; every other second locally) and whenever the test clock is advanced. Each merchant has its own clock – advance it in Dashboard → Subscriptions or through the API:
curl -X POST http://localhost:3100/v1/test_helpers/advance_clock -u "$VERACITY_PAY_SECRET_KEY:" \
  -d months=1 -d renewal_outcome=fail     # renewal_outcome: succeed | fail (omit to let the saved payment method decide)

Webhooks

Each event is POSTed as JSON (an event object) to every active endpoint. The Veracity-Signature: t=<unix>,v1=<hex> header contains an HMAC-SHA256 of `${t}.${rawBody}` using the endpoint's whsec_… secret. The same value is also sent as Stripe-Signature, so stripe.webhooks.constructEvent works unchanged. Reject requests older than 5 minutes. Respond with a 2xx quickly; anything else counts as a failure.

Retries: after 10 s, 30 s, 2 min, 10 min, 1 h, 6 h, 24 h – 8 attempts in total. Every attempt (status code, response, timing) is shown under Dashboard → Webhooks, where you can also resend it. Events can arrive more than once and out of order – make your handler idempotent on event.id.

EventWhen
checkout.session.completedThe session is complete. payment_status = paid, or unpaid while the payment is still processing (asynchronous).
checkout.session.async_payment_succeeded / _failedA pending payment from the session has settled.
checkout.session.expiredThe session expired (time limit, timeout outcome or /expire).
payment_intent.created / requires_action / processingThe payment's lifecycle.
payment_intent.succeededThe funds have been (pretend) captured.
payment_intent.payment_failedDeclined. last_payment_error.code/decline_code gives the reason.
payment_intent.canceledCanceled or expired (status canceled/expired).
charge.succeeded / charge.failed / charge.refundedThe charge object; charge.refunded is sent for both full and partial refunds.
refund.createdA new refund.
customer.created / updated / deletedCustomers.
customer.subscription.createdA new subscription (status active, or incomplete if the first payment is asynchronous).
customer.subscription.updatedNew period, past_due, active again, or cancel_at_period_end changed. previous_attributes shows what changed.
customer.subscription.deletedThe subscription has ended (status canceled).
invoice.created / finalizedThe invoice for a period.
invoice.paid / invoice.payment_succeededThe period is paid. billing_reason = subscription_create or subscription_cycle.
invoice.payment_failedThe renewal charge failed. The subscription becomes past_due.
invoice.marked_uncollectibleAll four attempts failed; the subscription is canceled (customer.subscription.deleted).
invoice.voidedAn asynchronous first payment failed (the subscription becomes incomplete_expired).

Verify the signature (Node)

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyVeracitySignature(rawBody: string, header: string | null, secret: string) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "");
  return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected));
}

Test mode and outcomes

Each flavour's checkout follows that provider's order of steps: Stripe on one page with the e-mail in the form, Nets with your details (e-mail, postcode, mobile) first, Adyen with the payment methods first, and Kustom with e-mail and postcode, then name and address, then Complete purchase. Every verification the real method would need – 3-D Secure for cards, the Swish app, BankID for invoice and bank transfer, Face ID for wallets – is done with a six-digit code sent to the payer's e-mail (valid for 10 minutes, 5 incorrect attempts). Cancel the verification declines the payment with authentication_failed. The collapsible Test mode – choose an outcome panel offers Automatic, Approve, Decline (insufficient funds, card declined, expired card, suspected fraud, credit check failed, verification failed), Pending (succeeds or fails after N seconds, with a webhook later), Timeout (the payer never confirms – the session expires after N seconds) and Provider error (HTTP 500). Invoice asks for a personal identity number; use the test number 19121212-1212.

Order of precedence when the outcome is decided: the choice in the panel → magic card number → metadata[veracity_test_outcome] → magic amount → approve.

Magic card numbers

Card numberOutcome
4242 4242 4242 4242Visa – succeeds
5555 5555 5555 4444Mastercard – succeeds
4000 0000 0000 0002Declined: card declined
4000 0000 0000 9995Declined: insufficient funds
4000 0000 0000 0069Declined: expired card
4100 0000 0000 0019Declined: suspected fraud
4000 0000 0000 3220Requires 3-D Secure authentication
4000 0000 0000 0150Pending, succeeds after 5 s
4000 0000 0000 0168Pending, fails after 5 s
4000 0000 0000 0119Provider error (500)
4000 0000 0000 0184Timeout – the session expires
4000 0000 0000 0341Succeeds, but renewals fail

Any other number (12–19 digits) is approved; the card brand is derived from the prefix (4 = Visa, 51–55/2221–2720 = Mastercard, 34/37 = Amex). The expiry date must be in the future.

Magic amounts

If the total amount ends in one of these öre values (and is at least 1 kr), it determines the outcome, regardless of payment method:

AmountOutcome
…,01 kr (e.g. 10001)Declined: card declined
…,02 kr (e.g. 10002)Declined: insufficient funds
…,03 kr (e.g. 10003)Declined: expired card
…,04 kr (e.g. 10004)Declined: suspected fraud
…,05 kr (e.g. 10005)Declined: credit check failed (invoice)
…,06 kr (e.g. 10006)Requires an extra authentication step
…,07 kr (e.g. 10007)Pending, succeeds after 5 s
…,08 kr (e.g. 10008)Pending, fails after 5 s
…,09 kr (e.g. 10009)Timeout – the session expires

Without the UI: metadata and test helpers

Set metadata[veracity_test_outcome] on the session (or PaymentIntent) to one of: approve, decline_card_declined, decline_insufficient_funds, decline_expired_card, decline_fraudulent, decline_credit_rejected, challenge, pending_succeed, pending_fail, timeout, provider_error. metadata[veracity_test_delay] sets the number of seconds for pending/timeout. For automated tests, an open session can be completed directly:

curl -X POST http://localhost:3100/v1/test_helpers/checkout/sessions/cs_test_…/complete \
  -u "$VERACITY_PAY_SECRET_KEY:" \
  -d outcome=approve            # or decline_insufficient_funds, pending_succeed, timeout …
  # optional: -d payment_method_type=invoice  -d card_number=4000000000000341  -d email=…  -d delay=3

Test payment methods for POST /v1/payment_intents (confirm=true) and POST /v1/customers (payment_method): pm_card_visa, pm_card_mastercard, pm_card_chargeDeclined, pm_card_chargeDeclinedInsufficientFunds, pm_card_chargeDeclinedExpiredCard, pm_card_chargeDeclinedFraudulent, pm_card_threeDSecureRequired, pm_card_pendingSucceed, pm_card_pendingFail, pm_card_processingError, pm_card_renewalsFail. If the payment requires an extra step, the API responds with status: requires_action and next_action.redirect_to_url.url.

A saved payment method using the card 4000 0000 0000 0341 (or subscription_data[metadata][veracity_renewal_outcome]=fail) makes every renewal fail.

Endpoints (Stripe flavour)

MethodPathDescription
POST/v1/checkout/sessionsCreate a Checkout Session (mode payment | subscription)
GET/v1/checkout/sessionsList (payment_intent, customer, subscription, status)
GET/v1/checkout/sessions/:idRetrieve (expand[]: line_items, payment_intent, customer, subscription, invoice)
GET/v1/checkout/sessions/:id/line_itemsLine items
POST/v1/checkout/sessions/:id/expireExpire an open session
POST/v1/payment_intentsCreate (confirm=true + payment_method=pm_card_… to pay immediately)
GET/v1/payment_intents[/:id]List / retrieve
POST/v1/payment_intents/:idUpdate metadata, description, amount
POST/v1/payment_intents/:id/confirmConfirm with a test payment method
POST/v1/payment_intents/:id/cancelCancel
GET/v1/charges/:idRetrieve a charge (ch_test_…)
POST/v1/refundsFull or partial refund (payment_intent, amount, reason)
GET/v1/refunds[/:id]List / retrieve
POST/GET/DELETE/v1/customers[/:id]Customers (email, name, phone, address, metadata, payment_method)
POST/GET/v1/products[/:id]Products (name, description, default_price_data)
POST/GET/v1/prices[/:id]Prices (unit_amount, currency, recurring[interval]=month|year, lookup_key)
POST/v1/subscriptionsCreate for a customer with a saved payment method
GET/v1/subscriptions[/:id]List (customer, price, status) / retrieve (expand[]=latest_invoice)
POST/v1/subscriptions/:idcancel_at_period_end, metadata
DELETE/v1/subscriptions/:idCancel immediately (also POST …/cancel)
GET/v1/invoices[/:id]Invoices (customer, subscription, status)
GET/v1/events[/:id]Event log (type)
POST/GET/DELETE/v1/webhook_endpoints[/:id]Webhook endpoints (the response includes the secret)
POST/v1/test_helpers/advance_clockAdvance the test clock: days, months, seconds or frozen_time; renewal_outcome
GET/v1/test_helpers/clockThe merchant's current test time
POST/v1/test_helpers/subscriptions/:id/next_charge_outcomeOutcome of the next automatic charge: outcome = automatic | approve | insufficient_funds | restricted_card | timeout, always = true|false
POST/v1/test_helpers/checkout/sessions/:id/completeComplete a session without the UI (outcome, payment_method_type, card_number, email)

Differences from Stripe: PaymentIntents also have the statuses failed and expired; subscriptions have one price each; the interval is month or year. The full list of deviations is in the box at the top of this section.

Integrate with Next.js

Copy lib/sdk.ts from the Veracity Pay repository into your app (no dependencies). It gives you a typed client and constructEvent for webhooks.

// app/api/billing/checkout/route.ts
import { createVeracityPay } from "@/lib/veracity-pay";

const vp = createVeracityPay({
  secretKey: process.env.VERACITY_PAY_SECRET_KEY!,
  baseUrl: process.env.VERACITY_PAY_BASE_URL!,
});

export async function POST(req: Request) {
  const { associationId, priceId, email } = await req.json();
  const session = await vp.checkout.sessions.create(
    {
      mode: "subscription",
      line_items: [{ price: priceId, quantity: 1 }],
      customer_email: email,
      client_reference_id: associationId,
      subscription_data: { metadata: { association_id: associationId } },
      success_url: `${process.env.APP_URL}/betalning/klar?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.APP_URL}/planer`,
    },
    { idempotencyKey: `checkout-${associationId}-${priceId}` },
  );
  return Response.json({ url: session.url });
}
// app/api/webhooks/veracity/route.ts
import { constructEvent, type Subscription } from "@/lib/veracity-pay";

export async function POST(req: Request) {
  const raw = await req.text();
  let event;
  try {
    event = await constructEvent(raw, req.headers.get("veracity-signature"), process.env.VERACITY_PAY_WEBHOOK_SECRET!);
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
  switch (event.type) {
    case "checkout.session.completed":            // link customer + subscription to the association
    case "customer.subscription.created":
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = event.data.object as unknown as Subscription;
      // store sub.id, sub.status, sub.current_period_end, sub.items.data[0].price.id …
      break;
    }
    case "invoice.paid":
    case "invoice.payment_failed":
      break;
  }
  return Response.json({ received: true });
}

Nets Easy flavour

Base URL /nets/v1, authenticated with Authorization: vp_sk_test_… (no Bearer, as with Nets). Only integrationType: "HostedPaymentPage" is supported.

  • POST /nets/v1/payments – order.items[] (grossTotalAmount, taxRate 2500 = 25%, taxAmount), order.amount (must equal the sum), checkout.returnUrl, cancelUrl, termsUrl, charge, notifications.webHooks[] → { paymentId, hostedPaymentPageUrl }
  • The payer is returned to returnUrl?paymentid=….
  • GET /nets/v1/payments/:paymentId – payment.summary (reservedAmount, chargedAmount, refundedAmount, cancelledAmount), paymentDetails, orderDetails, charges, refunds.
  • POST …/payments/:id/charges → chargeId; POST /nets/v1/charges/:chargeId/refunds or …/payments/:id/refunds → refundId; POST …/payments/:id/cancels → 204.
  • Webhooks are sent to the URLs in notifications.webHooks, with their authorization value in the Authorization header: payment.created, payment.reservation.created.v2, payment.checkout.completed, payment.reservation.failed, payment.charge.created.v2, payment.refund.completed, payment.cancel.created. Body: { id, merchantId, timestamp, event, data: { paymentId, … } }.

Adyen flavour

Base URL /adyen/v71 (v68–v71 are accepted), authenticated with X-API-Key: vp_sk_test_….

  • POST /adyen/v71/sessions – merchantAccount, amount {value, currency}, reference, returnUrl, mode: "hosted", lineItems (amountIncludingTax, taxPercentage) → { id: "CS…", url, sessionData, expiresAt, … }. The shopper is returned to returnUrl?sessionId=…&sessionResult=….
  • GET /adyen/v71/sessions/:id → status: active, completed, paymentPending, refused, canceled, expired.
  • POST /adyen/v71/payments – paymentMethod { type: "scheme", encryptedCardNumber: "test_4111111111111111", … } (or number) → resultCode Authorised, Refused, Pending, RedirectShopper (with action.url) and pspReference. Magic card numbers apply.
  • POST …/payments/:pspReference/refunds and …/cancels → status: "received".
  • Notifications: add an endpoint with the Adyen format in the Dashboard (or veracity_format=adyen on /v1/webhook_endpoints). It uses the standard format { live: "false", notificationItems: [{ NotificationRequestItem: { eventCode, success, pspReference, … additionalData.hmacSignature } }] } with AUTHORISATION, REFUND and CANCELLATION. The HMAC key is shown on the endpoint; the signature is computed as with Adyen (pspReference:originalReference:merchantAccountCode:merchantReference:value:currency:eventCode:success, HMAC-SHA256 with the hex key, base64).

Kustom flavour (formerly Klarna Checkout)

Base URL /kustom, HTTP Basic with any username (for example the merchant ID) and the secret key as the password.

  • POST /kustom/checkout/v3/orders – purchase_country, purchase_currency, locale, order_amount, order_tax_amount, order_lines (unit_price, tax_rate, total_amount, total_tax_amount), merchant_urls (terms, checkout, confirmation, push with {checkout.order.id}) → order_id, status: checkout_incomplete, html_snippet (an iframe containing the checkout).
  • GET /kustom/checkout/v3/orders/:id → checkout_complete after the purchase; an expired order returns 404.
  • Push: a POST without a body to merchant_urls.push a few seconds after the purchase; retried on failure.
  • Order management: GET /kustom/ordermanagement/v1/orders/:id, POST …/acknowledge, …/captures (captured_amount), …/refunds (refunded_amount), …/cancel, …/merchant-references.

Swish Handel flavour

Swish Commerce API: payment requests (e-commerce and m-commerce), refunds, callbacks and recurring payments – without certificates. Base URL /swish. Full step-by-step guide with calls, callbacks and test outcomes: /guides/swish.md.

Svea Checkout flavour

Checkout API and Payment Admin with Svea-signed calls, push and delivery/credit. Base URL /svea. Full step-by-step guide with calls, callbacks and test outcomes: /guides/svea.md.

Walley Checkout flavour

Checkout with OAuth2, notifications and order management (activate, refund, cancel). Base URL /walley. Full step-by-step guide with calls, callbacks and test outcomes: /guides/walley.md.

Qliro One flavour

Merchant API with Qliro-signed calls, status push and order management. Base URL /qliro. Full step-by-step guide with calls, callbacks and test outcomes: /guides/qliro.md.

Simulated outages

Under Outages in the dashboard (or with the MCP tool simulate_outage) you switch on an outage for one payment partner or all, for a set time or until switched off. It applies to your account only and answers in each partner's own error format. The test helpers work as usual, so a test can still complete payments.

  • Invalid key (invalid_key): Every call answers 401 as if the key were wrong.
  • Expired key (expired_key): Every call answers 401 as if the key had expired.
  • Slow responses (slow): Every call is delayed, then answered as usual.
  • Timeout (timeout): Calls hang for 28 seconds, then get 504 Gateway Timeout.
  • Service down (offline): Every call gets 503 Service Unavailable, as from a load balancer.
  • Server error (server_error): Every call gets a 500 in the provider's own format.
  • Rate limit (rate_limited): Every call gets 429 with Retry-After: 30.
  • Flaky service (flaky): A share of the calls gets 500 or 503, the rest work.
  • Paused webhooks (paused): deliveries and notifications wait and are sent when the pause ends.

My payments (customer)

At /konto, payers sign in with their email and a six-digit code (valid for 10 minutes, 5 attempts per code) and see, across all merchants:

  • Payment history with receipts.
  • Unpaid invoice and instalment purchases (“Pay in 30 days” or three monthly instalments) with due dates. Instalments are charged automatically on the due date (by the merchant's clock) and can also be paid right away with a code. The merchant has already received the full amount; a refund reduces the outstanding balance.
  • Active subscriptions with the next charge date and amount, and buttons to cancel at the end of the period or immediately.
  • A test setting per subscription and per upcoming instalment: On the next charge: Approve / Decline (insufficient funds) / Decline (card blocked) / Timeout, with “Always” to keep the choice.
  • Saved payment methods per merchant. Removing one makes the next renewal with that merchant fail.

Email and Test inbox

Everything that is “sent” – payment codes, sign-in codes, receipts, invoices, refunds – ends up in the Test inbox, searchable by recipient. If RESEND_API_KEY and EMAIL_FROM are set, messages are also delivered for real through Resend. Setting VERACITY_INBOX_KEY locks the inbox behind a key.

Operations

  • Locally: pnpm dev (port 3100). The database is PGlite in .data/pglite and is migrated automatically.
  • With DATABASE_URL set, Neon (serverless HTTP) is used. Run pnpm db:migrate against the database.
  • The clock (expired sessions, pending payments, renewals, webhook retries) ticks every other second under next dev/next start, on API requests and via Vercel Cron on /api/cron/tick (protected with CRON_SECRET).
  • Other environment variables: VERACITY_BASE_URL (public URL), VERACITY_ALLOW_SEED=1 (allow /api/dev/seed in production).