Skip to content

Developers

A payments API that tells the truth about money

Amounts are exact decimal strings with integer twins. Status is derived only from verified on-chain facts. Webhooks are signed, retried and idempotent. Never fulfil from a redirect.

Quickstart

Charge each order at its real total, send the customer to checkout, and fulfil from a signed webhook. These are the same four steps in every language, and every sample below is a real file we run against the API.

  1. 1

    Get your keys

    Create a test account, then open API keys in the dashboard for a secret key (sk_test_…) and Webhooks for an endpoint secret. Keep both on your server, as environment variables.

    .env
    PLINTH_API_URL=https://plinth-api.zerolayer.online
    PLINTH_SECRET_KEY=sk_test_...
    PLINTH_WEBHOOK_SECRET=whsec_...
  2. 2

    Create a payment when the customer checks out

    Compute the total on your server and send it as a string. The Idempotency-Key makes a retry return the same payment instead of a second one. Then redirect the customer to checkout_url.

    create-payment.sh
    #!/usr/bin/env sh
    # Create a payment for one order. PLINTH_API_URL and PLINTH_SECRET_KEY come from your environment.
    curl -sS -X POST "$PLINTH_API_URL/v1/payment-intents" \
      -H "Authorization: Bearer $PLINTH_SECRET_KEY" \
      -H "Idempotency-Key: order-1042-1" \
      -H "Content-Type: application/json" \
      -d '{
        "amount": "25.00",
        "order_reference": "order-1042",
        "success_url": "https://shop.example/thanks",
        "cancel_url": "https://shop.example/cart"
      }'

    Tested: each file is run against a live API by scripts/test-doc-examples.mjs.

    What comes back (abridged)

    response
    {
      "id": "pi_9e78c740ce82e30fdbed754ad9a97ea6",
      "status": "AWAITING_PAYMENT",
      "amount": "25.00",
      "amount_units": "25000000",
      "currency": "USDC",
      "order_reference": "order-1042",
      "checkout_url": "https://plinth.zerolayer.online/pay/pi_9e78c740ce82e30fdbed754ad9a97ea6",
      "success_url": "https://YOUR_DOMAIN/thanks",
      "expires_at": "2026-10-14T14:48:00.720Z",
      "networks": [{
        "network": "arc-testnet",
        "usdc_address": "0x3600000000000000000000000000000000000000",
        "deposit_address": "0x08D7…7188",
        "wallet_checkout_supported": true
      }]
    }

    Taken from a real response on a local test chain; the network entry is shown for Arc Testnet. The deposit address is unique to this payment and no contract is deployed for it yet.

  3. 3

    Fulfil from the webhook, never from the redirect

    Add an endpoint in the dashboard that points at your server. This receiver verifies the signature on the raw body, rejects stale timestamps, ignores an event it has already seen, and acts on payment.confirmed.

    webhook-server.mjs
    // Node 18+, no dependencies. Receives Plinth webhooks, verifies them, and acts once per event.
    import { createHmac, timingSafeEqual } from "node:crypto";
    import { createServer } from "node:http";
    
    const SECRET = process.env.PLINTH_WEBHOOK_SECRET;
    const seen = new Set(); // use a database table in production, so a restart cannot cause a double delivery
    
    export function verify(rawBody, header, secret = SECRET, now = Math.floor(Date.now() / 1000)) {
      const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
      const t = Number(parts.t);
      if (!Number.isFinite(t) || !parts.v1 || Math.abs(now - t) > 300) return false;
      const want = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex");
      const got = Buffer.from(parts.v1, "hex");
      return got.length === want.length && timingSafeEqual(got, want);
    }
    
    export function onPaid(orderReference) {
      console.log("paid:", orderReference); // mark the order paid and fulfil it here
    }
    
    createServer(async (req, res) => {
      if (req.method !== "POST" || req.url !== "/webhooks/plinth") return res.writeHead(404).end();
      const chunks = [];
      for await (const c of req) chunks.push(c);
      const raw = Buffer.concat(chunks).toString("utf8"); // verify the raw bytes, before any JSON parsing
      if (!verify(raw, req.headers["plinth-signature"])) return res.writeHead(401).end();
      const event = JSON.parse(raw);
      if (!seen.has(event.id)) {
        seen.add(event.id);
        if (event.type === "payment.confirmed") onPaid(event.data.object.order_reference);
      }
      res.writeHead(200).end(); // a non-2xx makes Plinth retry later
    }).listen(Number(process.env.PORT ?? 8788));

    Tested: valid, tampered, stale and missing signatures, and a replayed event, in both languages.

  4. 4

    Test it end to end

    Pay the test checkout from a wallet with test USDC, or send test USDC to the deposit address. Your order should turn paid from the webhook alone. Then check the two failure cases: a webhook with a bad signature must be rejected (401), and a replayed event must change nothing.

    Prefer an AI assistant? It can do these four steps for you.

API

Versioned under /v1. Test keys (sk_test_) operate on testnets, live keys on mainnets. Keys are stored hashed. The full OpenAPI 3.1 document is served by the API at /v1/openapi.json and committed to the repository as docs/openapi.json.

Open the OpenAPI document

Core endpoints

  • POST/v1/payment-intents

    Create a payment (idempotent)

  • GET/v1/payment-intents/{id}

    Retrieve a payment

  • GET/v1/payments

    List and search payments

  • POST/v1/payment-links

    Create a reusable link

  • POST/v1/invoices

    Create a numbered invoice

  • GET/v1/networks · /v1/assets

    Networks, capabilities, official assets

  • GET/v1/settlements · /v1/transactions

    Settlement and on-chain movements

  • POST/v1/refund-requests

    Request a refund; completes when your vault transfer is verified

  • POST/v1/webhook-endpoints

    Register a signed webhook endpoint

  • GET/v1/webhook-deliveries

    Delivery log, with manual retry

  • GET/v1/settlement-policy

    Accounting policy and live on-chain vault policy

Errors

errors
{ "error": { "type": "invalid_request_error", "code": "VALIDATION_FAILED",
    "message": "Request validation failed", "request_id": "req_…",
    "details": [{ "path": "amount", "message": "Amount must be a decimal string…" }] } }

Webhooks

Each request is signed: Plinth-Signature: t=<unix>,v1=<hex> where v1 is HMAC-SHA256 of `${t}.${rawBody}` with your endpoint secret. Reject timestamps older than 5 minutes. Failed deliveries are retried 8 times over about 10 hours; every attempt is logged in the dashboard. Deduplicate on event.id.

Signature header
Plinth-Signature
Timestamp tolerance
Reject older than 5 minutes
Retries
8 over about 10 hours
Deduplicate on
event.id
Webhook events
EventMeaning
payment.detectedA transfer was seen and verified. Not final.
payment.confirmedFinal funds satisfy the amount and your policy. Safe to fulfil.
payment.completedFunds are confirmed inside the merchant vault.
payment.underpaidExpired with only part of the amount.
payment.overpaidMore than the amount arrived (excess in data).
payment.expiredExpired with nothing received.
payment.lateFunds became final after expiry.
payment.invalid_assetA non-official token was sent.
settlement.submittedRelayer broadcast the sweep.
settlement.completedSweep is final.
settlement.failedSweep could not execute; being retried.
refund.requestedA refund was requested.
refund.completedThe refund transfer was verified on-chain.
payout.requested
payout.confirmed
payout.switched_to_vault
payout.cancelled

Payment states

Status is derived from verified facts; it is never set by a client. Multiple transfers add up. Past expiry, partial funds are underpaid and funds that become final late are a late payment.

  • CreatedCREATED

    Created, not yet open for payment.

  • Awaiting paymentAWAITING_PAYMENT

    Waiting for funds.

  • DetectedDETECTED

    A transfer was seen on-chain. Not final yet.

  • ConfirmingCONFIRMING

    Funds seen; waiting for network finality or the remaining amount.

  • PaidPAID

    Final funds cover the amount. Settlement is about to start.

  • OverpaidOVERPAID

    More than the requested amount was received.

  • SettlingSETTLEMENT_PENDING

    Paid in full. Moving funds into the merchant vault.

  • SettledSETTLED

    Funds are confirmed inside the merchant vault.

  • ExpiredEXPIRED

    Expired without payment.

  • UnderpaidUNDERPAID

    Expired with only part of the amount received.

  • Late paymentLATE_PAYMENT

    Funds arrived after expiry.

  • Wrong tokenINVALID_ASSET

    A token other than the official payment token was sent.

  • Settlement failedSETTLEMENT_FAILED

    Funds are final but the sweep failed. Retrying.

  • Refund requestedREFUND_REQUESTED

    A refund is waiting for the merchant to send it.

  • Partly refundedPARTIALLY_REFUNDED

    Part of the payment was returned.

  • RefundedREFUNDED

    Returned to the customer.

Test in the sandbox

Everything runs on testnets with test keys. No real funds are involved at any step.

  1. 1

    Create an account

    Sign up and enable a testnet. No card, no KYC.

  2. 2

    Create a vault

    Name a wallet you control as the owner. The relayer pays for the deployment.

  3. 3

    Make a test key and a payment

    Create an sk_test_ key and call POST /v1/payment-intents.

  4. 4

    Pay it and watch the webhook

    Fund a wallet from Circle's faucet, pay your own checkout and receive signed events.

Create a free accountWatch a payment in the demo

Locally, pnpm dev:stack starts a full stack with a seeded merchant, a local chain and test USDC.