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
/v1is 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 haslivemode: 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/mcpCursor, 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.txtFor agents without MCP
- /llms.txt and /llms-full.txt – the whole reference as Markdown.
- /guides/stripe.md, nets.md, adyen.md, kustom.md – integration guides per payment partner.
- /sdk.ts – the TypeScript client · /listen.mjs – forwards webhooks to localhost.
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
- Sign up as a merchant: enter a name, legal form and contact email, 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. - Set the name, logo, colour, VAT rate and payment methods under Settings. Keys can be rolled (the old ones are revoked immediately).
- Add a webhook endpoint under Webhooks and copy the signing secret (whsec_…).
- 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/kassaSubscription (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 eitherpayment_intent.succeeded+invoice.paid+invoice.payment_succeeded, orpayment_intent.payment_failed+invoice.payment_failed+customer.subscription.updatedwithstatus: 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
activeagain. If all of them fail, the invoice becomesuncollectibleand the subscriptioncanceled(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,timeoutorautomatic;always=truemakes it persistent). The setting applies to one charge and is then reset. Timeout means the charge staysprocessingfor 30 s and then fails. Without a saved payment method, the charge always fails. The subscription object shows the setting inveracity_next_charge. - Cancellation: at the end of the period with
POST /v1/subscriptions/:idandcancel_at_period_end=true(undo withfalse), or immediately withDELETE /v1/subscriptions/:id(orPOST /v1/subscriptions/:id/cancel). Both sendcustomer.subscription.deletedwhen the subscription ends. - Time: renewals run in real time (Vercel Cron every minute against
/api/cron/tick, protected withCRON_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. 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.
| Event | When |
|---|---|
checkout.session.completed | The session is complete. payment_status = paid, or unpaid while the payment is still processing (asynchronous). |
checkout.session.async_payment_succeeded / _failed | A pending payment from the session has settled. |
checkout.session.expired | The session expired (time limit, timeout outcome or /expire). |
payment_intent.created / requires_action / processing | The payment's lifecycle. |
payment_intent.succeeded | The funds have been (pretend) captured. |
payment_intent.payment_failed | Declined. last_payment_error.code/decline_code gives the reason. |
payment_intent.canceled | Canceled or expired (status canceled/expired). |
charge.succeeded / charge.failed / charge.refunded | The charge object; charge.refunded is sent for both full and partial refunds. |
refund.created | A new refund. |
customer.created / updated / deleted | Customers. |
customer.subscription.created | A new subscription (status active, or incomplete if the first payment is asynchronous). |
customer.subscription.updated | New period, past_due, active again, or cancel_at_period_end changed. previous_attributes shows what changed. |
customer.subscription.deleted | The subscription has ended (status canceled). |
invoice.created / finalized | The invoice for a period. |
invoice.paid / invoice.payment_succeeded | The period is paid. billing_reason = subscription_create or subscription_cycle. |
invoice.payment_failed | The renewal charge failed. The subscription becomes past_due. |
invoice.marked_uncollectible | All four attempts failed; the subscription is canceled (customer.subscription.deleted). |
invoice.voided | An 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
Checkout has a clearly marked Test mode – choose an outcome panel: Automatic, Approve, Decline (insufficient funds, card declined, expired card, suspected fraud, credit check failed), Extra step (3-D Secure for cards, BankID-style for other methods), 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). The approval code is valid for 10 minutes and allows 5 incorrect attempts.
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 number | Outcome |
|---|---|
4242 4242 4242 4242 | Visa – succeeds |
5555 5555 5555 4444 | Mastercard – succeeds |
4000 0000 0000 0002 | Declined: card declined |
4000 0000 0000 9995 | Declined: insufficient funds |
4000 0000 0000 0069 | Declined: expired card |
4100 0000 0000 0019 | Declined: suspected fraud |
4000 0000 0000 3220 | Requires 3-D Secure authentication |
4000 0000 0000 0150 | Pending, succeeds after 5 s |
4000 0000 0000 0168 | Pending, fails after 5 s |
4000 0000 0000 0119 | Provider error (500) |
4000 0000 0000 0184 | Timeout – the session expires |
4000 0000 0000 0341 | Succeeds, 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:
| Amount | Outcome |
|---|---|
…,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=3Test 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)
| Method | Path | Description |
|---|---|---|
POST | /v1/checkout/sessions | Create a Checkout Session (mode payment | subscription) |
GET | /v1/checkout/sessions | List (payment_intent, customer, subscription, status) |
GET | /v1/checkout/sessions/:id | Retrieve (expand[]: line_items, payment_intent, customer, subscription, invoice) |
GET | /v1/checkout/sessions/:id/line_items | Line items |
POST | /v1/checkout/sessions/:id/expire | Expire an open session |
POST | /v1/payment_intents | Create (confirm=true + payment_method=pm_card_… to pay immediately) |
GET | /v1/payment_intents[/:id] | List / retrieve |
POST | /v1/payment_intents/:id | Update metadata, description, amount |
POST | /v1/payment_intents/:id/confirm | Confirm with a test payment method |
POST | /v1/payment_intents/:id/cancel | Cancel |
GET | /v1/charges/:id | Retrieve a charge (ch_test_…) |
POST | /v1/refunds | Full 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/subscriptions | Create for a customer with a saved payment method |
GET | /v1/subscriptions[/:id] | List (customer, price, status) / retrieve (expand[]=latest_invoice) |
POST | /v1/subscriptions/:id | cancel_at_period_end, metadata |
DELETE | /v1/subscriptions/:id | Cancel 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_clock | Advance the test clock: days, months, seconds or frozen_time; renewal_outcome |
GET | /v1/test_helpers/clock | The merchant's current test time |
POST | /v1/test_helpers/subscriptions/:id/next_charge_outcome | Outcome of the next automatic charge: outcome = automatic | approve | insufficient_funds | restricted_card | timeout, always = true|false |
POST | /v1/test_helpers/checkout/sessions/:id/complete | Complete 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; and the signature header is called Veracity-Signature.
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/refundsor…/payments/:id/refunds→refundId;POST …/payments/:id/cancels→ 204.- Webhooks are sent to the URLs in
notifications.webHooks, with theirauthorizationvalue in theAuthorizationheader: 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 toreturnUrl?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) →resultCodeAuthorised, Refused, Pending, RedirectShopper (withaction.url) andpspReference. Magic card numbers apply.POST …/payments/:pspReference/refundsand…/cancels→status: "received".- Notifications: add an endpoint with the Adyen format in the Dashboard (or
veracity_format=adyenon/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_completeafter 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.
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/pgliteand is migrated automatically. - With
DATABASE_URLset, Neon (serverless HTTP) is used. Runpnpm db:migrateagainst 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 withCRON_SECRET). - Other environment variables:
VERACITY_BASE_URL(public URL),VERACITY_ALLOW_SEED=1(allow /api/dev/seed in production).