The Desk API
Eine REST-API, um Karten und Krypto zu charguen und auf eine USDC-Wallet zu settlen, die du kontrollierst. Ein POST erstellt einen Checkout. Ein Webhook sagt dir, dass bezahlt wurde. Live in unter 30 Minuten.
Getting Started
The Desk unterstützt vier Integrationsmodi. Wähl den, der zu deinen Anforderungen passt; die darunterliegende API ist dieselbe.
Drop-in Button, kein Signup. Gib deine USDC-Wallet-Adresse im Request Body mit. Null Backend-State; public, sichtbar in DevTools.
Server-to-Server. Versteck deine Wallet hinter Bearer sk_live_. Branding, Webhooks, Mass Payouts im Dashboard konfigurieren.
Fertiges Plugin. ZIP hochladen, API-Key + Webhook-Secret einfügen, Bestellungen werden bei Zahlung automatisch abgeschlossen. HPOS-ready, WC 7.0+.
Custom App + Manuelle Zahlungsmethode. ~30 Min Installation auf einem bestehenden Store. Bestellungen werden via Shopify Admin API als bezahlt markiert.
Quickstart (5 Min.)
Drei Schritte: Session erstellen, Kunden redirecten, Webhook behandeln. Das Sample unten ist eine produktionsreife Node.js-Checkout-Route.
// Create a checkout session and redirect your customer.
// Authorization resolves the merchant wallet server-side — no wallet in the body.
const res = await fetch('https://pay.qistdigital.com/api/v1/checkout/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.QIST_API_KEY}`, // sk_live_…
},
body: JSON.stringify({
amount_cents: 5000, // €50.00 — integer, in cents
currency: 'EUR', // EUR, USD, GBP, CAD, AUD, CHF
email: 'buyer@example.com', // optional, shown in checkout
success_url: 'https://mystore.com/success',
cancel_url: 'https://mystore.com/cart',
webhook_url: 'https://mystore.com/api/qist-webhook',
metadata: { order_id: '1234' },
}),
});
const { id, url, tracking_number } = await res.json();
// => { id: 'cs_abc…', url: 'https://pay.qistdigital.com/session/cs_abc…',
// tracking_number: '0x…', provider: 'gateway', status: 'pending', … }
// Redirect your customer to the hosted checkout.
return Response.redirect(url, 303);Das ist der ganze Happy Path. Der Kunde landet auf einem gehosteten Checkout auf , wählt Karte oder Krypto, und dein Webhook feuert innerhalb von 30s nach Zahlung.
Authentifizierung
Zwei Schemata, je nach Integrationsmodus:
| Schema | Wie | Wann |
|---|---|---|
| Bearer Token | Authorization: Bearer sk_live_… | Serverseitig. Hält die Wallet privat. |
| Wallet im Body | { "wallet": "0x…", … } | Statische Sites / Widgets ohne Backend. |
sk_live_… and an sk_test_… key. Use sk_live_ as your canonical key — that is what every example here uses. The sk_test_ key is provided for the webhook-receiver simulator at /api/v1/test/fire-webhook. The Desksettles real on-chain USDC — there is no test network. To dry-run an integration, run a $1 real payment and refund yourself.API-Referenz
Base URL: . Alle Endpunkte sprechen JSON, liefern bei Erfolg ein einzelnes Objekt zurück und ein -Objekt bei 4xx/5xx.
#Eine Checkout-Session erstellen
Erzeugt eine gehostete Checkout-URL. Der Kunde öffnet sie, zahlt mit Karte oder Krypto, The Desk settlet auf deine Wallet in USDC, Webhook feuert.
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| amount_cents | integer | Pflicht | Betrag in der kleinsten Währungseinheit (Cent). Bereich 100 – 10 000 000. |
| currency | string | Pflicht | ISO-4217-Code. Unterstützt: EUR, USD, GBP, CAD, AUD, CHF. |
| wallet | string | one-of | USDC-Wallet auf Polygon (0x + 40 Hex). Pflicht, außer bei Auth via Bearer-Key. |
| customer_email | string | optional | Wird in der Checkout-UI angezeigt und an den On-Ramp für KYC-Wiederverwendung weitergeleitet. |
| success_url | url | optional | Redirect nach erfolgreicher Zahlung. Nur http/https. |
| cancel_url | url | optional | Redirect, falls der Kunde den Checkout abbricht. |
| webhook_url | url | optional | POST-Ziel für order.paid-Events. Überschreibt den Dashboard-Default. |
| provider | string | optional | Default 'gateway' (Smart Picker — empfohlen). Oder eine spezifische On-Ramp-ID aus GET /providers (z.B. moonpay, revolut, banxa, transak) pinnen. |
| product_name | string | optional | Label auf der Checkout-Seite (max. 80 Zeichen). |
| metadata | object | optional | Bis zu 10 String-Key/Value-Paare, die im Webhook zurückgespiegelt werden. Reservierter Key: order_id. |
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| id | string | optional | Session-ID, beginnt mit cs_. |
| url | string | optional | Gehostete Checkout-URL, auf die der Kunde redirectet wird. |
| status | string | optional | Immer "pending" bei Erstellung. |
| amount | integer | optional | Echo von amount_cents. |
| currency | string | optional | Echo der Währung. |
| provider | string | optional | Echo des Providers (Default 'gateway'). |
| expires_at | string | optional | ISO-8601-Ablaufdatum (24h ab Erstellung). |
| tracking_number | string | optional | Polygon-Settlement-Adresse — entspricht address_in im Webhook-Payload, nutzbar mit /track für Live-Monitoring. |
Beispiele
// Node.js 18+
const res = await fetch('https://pay.qistdigital.com/api/v1/checkout/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.QIST_API_KEY}`,
'Idempotency-Key': crypto.randomUUID(), // safe double-submit
},
body: JSON.stringify({
amount_cents: 5000,
currency: 'EUR',
email: 'buyer@example.com',
success_url: 'https://mystore.com/success',
cancel_url: 'https://mystore.com/cart',
metadata: { order_id: '1234' },
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { id, url } = await res.json();
return Response.redirect(url, 303);#Eine Session abrufen (Polling)
Nutze das als Webhook-Fallback oder um eine Success-Page nach Redirect zu hydraten. Serverseitig re-checken wir unseren Settlement-Layer bei jedem Call — günstig (<200ms), Polling alle 3-5s ist also ok.
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| id | string | optional | Session-ID. |
| status | string | optional | pending | paid | expired | failed. |
| amount | integer | optional | Originalbetrag in Cent. |
| currency | string | optional | Originalwährung. |
| paid_at | string|null | optional | ISO 8601, wann das On-Chain-Settlement abgeschlossen wurde. |
| paid_provider | string|null | optional | Welcher Provider die Zahlung tatsächlich abgewickelt hat (kann vom angefragten abweichen). |
| txid | string|null | optional | Polygon-Settlement-txid. Verlinken mit polygonscan.com/tx/{txid}. |
| expires_at | string | optional | ISO 8601 Ablaufdatum. |
// Poll every 3-5 seconds until terminal state. Use webhooks for push-
// delivery in production; polling is the fallback when webhooks are down.
async function waitForPayment(sessionId, { timeoutMs = 15 * 60 * 1000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`https://pay.qistdigital.com/api/v1/sessions/${sessionId}`);
const s = await res.json();
if (s.status === 'paid') return s; // terminal: success
if (s.status === 'expired') throw new Error('Session expired');
if (s.status === 'failed') throw new Error('Payment failed');
await new Promise(r => setTimeout(r, 4000));
}
throw new Error('Polling timeout');
}#Live-Provider-Matrix
Listet die aktuell Traffic akzeptierenden On-Ramps mit per-Provider-Mindestbeträgen. 5 Min. am Edge gecacht — beim App-Boot einmal pollen, nicht pro Request.
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| providers[].id | string | optional | Provider-Key (als `provider` in /checkout/init übergebbar). |
| providers[].provider_name | string | optional | Menschliches Label für das Dropdown. |
| providers[].status | string | optional | 'active' (an diesem Endpunkt immer auf active gefiltert). |
| providers[].minimum_currency | string | optional | ISO-Code des Mindestbetrags. |
| providers[].minimum_amount | number | optional | Niedrigster Betrag, den der Provider akzeptiert (in minimum_currency-Einheiten). |
curl -sS 'https://pay.qistdigital.com/api/v1/providers' | jq '.providers[] | {id, provider_name, minimum_currency, minimum_amount}'
# [
# { "id": "gateway", "provider_name": "Smart (recommended)", "minimum_currency": "USD", "minimum_amount": 1 },
# { "id": "moonpay", "provider_name": "Moonpay", "minimum_currency": "EUR", "minimum_amount": 20 },
# { "id": "revolut", "provider_name": "Revolut Ramp", "minimum_currency": "EUR", "minimum_amount": 10 },
# { "id": "binance", "provider_name": "Binance Pay", "minimum_currency": "EUR", "minimum_amount": 15 },
# …
# ]
# Cache: 5 minutes at the edge. Call once per deploy, not per request.Webhooks
Wenn eine Session einen terminalen Zustand erreicht, POSTen wir ein signiertes JSON-Event an die , die du konfiguriert hast (per Session oder im Dashboard). Immer den Request-Body für die Signaturverifizierung parsen — JSON neu serialisieren reordert Keys und bricht den HMAC.
POST /your-endpoint HTTP/1.1
Host: mystore.com
Content-Type: application/json
x-peptidepay-signature: t=1745300551,v1=3f9b5c1e8a7d… ← HMAC-SHA256, hex
{
"event": "order.paid",
"session_id": "cs_abc123",
"order_id": "1234",
"address_in": "0xAb12…",
"status": "paid",
"amount": 5000,
"currency": "EUR",
"txid": "0xfa89b2…",
"paid_at": "2026-04-23T10:02:31.000Z",
"attempt": 1
}Event-Typen
| Event | Wann |
|---|---|
| order.paid | On-Chain-Settlement bestätigt. + + garantiert vorhanden. Bestellung als bezahlt markieren. |
Heute wird nur ausgeliefert — abgelaufene und fehlgeschlagene Sessions sind via beobachtbar (Status geht nach 24h TTL auf ; terminale Fehler zeigen ). Wir könnten in einem zukünftigen Release Push-Events dafür ergänzen.
Signaturverifizierung
Merchants mit Signup-Account erhalten ein -Secret und jede Delivery trägt einen -Header der Form . Berechne und constant-time-vergleiche mit . Alles älter als 5 Minuten ablehnen.
// Node.js — Express/Next.js route handler
import crypto from 'node:crypto';
const SECRET = process.env.QIST_WEBHOOK_SECRET; // dashboard → Webhooks
export async function POST(req) {
const rawBody = await req.text(); // MUST be the raw bytes
const header = req.headers.get('x-peptidepay-signature') ?? '';
const [ tPart, v1Part ] = header.split(',');
const t = tPart?.split('=')[1];
const v1 = v1Part?.split('=')[1];
if (!t || !v1) return new Response('bad sig', { status: 400 });
// Reject replays older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300)
return new Response('stale', { status: 400 });
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
const ok =
v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
if (!ok) return new Response('invalid sig', { status: 401 });
const event = JSON.parse(rawBody);
// Idempotency: dedupe by event.session_id in your DB — retries re-fire
// the same event (with an incrementing "attempt" field) until you 2xx.
if (event.event === 'order.paid') {
await markOrderPaid(event.order_id, event.txid);
}
return new Response('ok');
}Retry-Policy
Wir retryen non-2xx-Responses (und Timeouts > 5s) mit exponentiellem Backoff. Sechs Versuche insgesamt über ~42 Stunden:
- Versuch 1 — sofort bei Bestätigung.
- Versuch 2 — +5 Minuten.
- Versuch 3 — +15 Minuten.
- Versuch 4 — +1 Stunde.
- Versuch 5 — +4 Stunden.
- Versuch 6 — +12 Stunden, dann +24 Stunden (final).
Nach 6 fehlgeschlagenen Versuchen wird das Event dead-lettered. Du kannst den aktuellen State jederzeit via abfragen.
Häufige Probleme
- Ich sehe 'invalid signature' bei jeder Delivery
- Dein Framework hat den Body als JSON geparst, bevor du ihn gehasht hast. Lies die ROHEN Bytes (Express: express.raw({type:'*/*'}); Next.js: req.text(); Laravel: request()->getContent(); Rails: request.raw_post). Nie vor dem Hashen neu serialisieren.
- IP-Whitelist — von welchen IPs sendet ihr?
- Deliveries kommen aktuell von Vercel Edge (dynamische IPs). Wir veröffentlichen keinen statischen Bereich. Wenn du whitelisten MUSST, nutz den Signature-Header als Auth-Gate und akzeptiere jede Source-IP — der HMAC ist der echte Identitäts-Check.
- HTTPS erforderlich?
- Ja. Wir weigern uns, an http://-Endpunkte zu POSTen (Confusable-Deputy-/Plaintext-Replay-Risiko). Die gratis HTTPS-URL von ngrok funktioniert fürs lokale Testen wunderbar.
- Mein Endpunkt ist langsam — kann ich das 5s-Timeout verlängern?
- Nein. Antworte sofort 2xx, dann asynchron verarbeiten (Job-Queue, setImmediate, Goroutine). Lange blockende Handler laufen immer in Timeouts.
SDKs
Die API ist klein genug, dass absolut in Ordnung ist — aber das Node-SDK gibt dir Types, automatische Retries und einen -Helper, der die Signaturverifizierung für dich übernimmt.
// npm install github:kinerette/peptide-pay-sdk
// The published package is still named "peptide-pay"; we alias the client
// to Qist here so your code reads cleanly.
import { PeptidePay as Qist } from 'peptide-pay';
const qist = new Qist(process.env.QIST_API_KEY);
// Create a session
const session = await qist.checkout.create({
amount_cents: 5000,
currency: 'EUR',
customer_email: 'buyer@example.com',
success_url: 'https://mystore.com/success',
cancel_url: 'https://mystore.com/cart',
metadata: { order_id: '1234' },
});
// Retrieve a session
const latest = await qist.sessions.retrieve(session.id);
// Verify + parse a webhook (throws on invalid signature)
app.post('/webhooks/qist', express.raw({ type: '*/*' }), (req, res) => {
const event = qist.webhooks.constructEvent(
req.body,
req.headers['x-peptidepay-signature'], // signature header is unchanged
process.env.QIST_WEBHOOK_SECRET,
);
// event.event === 'order.paid' (currently the only event delivered)
res.sendStatus(200);
});The DeskVolle Types, Webhook-Helper, automatische Retries.
beliebige SpracheEin POST, ein GET. Keine Lib nötig.
Gebühren
Flat — The Desks volle Kommission. Kein Abo, keine Monatsgebühr, keine Chargeback-Gebühren. On-Ramp-Gebühren für Karten (~4.5%, vom Upstream-Kartenprocessor erhoben) sind Pass-Through — der Kunde zahlt sie, sie berühren nie deinen Payout.
| Zahlungsmethode | Du zahlst | Kunde zahlt |
|---|---|---|
| Karte / Apple Pay / Google Pay | 3% | ~4.5% (On-Ramp, Pass-Through) |
| Krypto direkt (USDC → USDC) | 3% | nur Gas (~$0.01 auf Polygon) |
Volle Aufschlüsselung mit Rechenbeispielen unter /fees.
Testing
The Desk hat kein Sandbox-Netzwerk — jedes Settlement ist ein echter On-Chain-Transfer. Der risikoärmste Test ist , das du dir selbst zurückerstattest. Um deinen Webhook end-to-end ohne Zahlung auszuprobieren, nutz den Test-Simulator unten.
- Webhook-Simulator: POST an /api/v1/test/fire-webhook mit einem sk_test_-Key, um ein signiertes order.paid an deinen Endpunkt zu feuern — keine Zahlung, kein Refund nötig.
- MoonPay-Dev-Karte: , beliebiges zukünftiges Ablaufdatum, beliebige CVV, ZIP 10001.
- Lokales Webhook-Testing: localhost mit ngrok exponieren, URL pro Session ins -Feld einfügen.
Voller lokaler Loop (ngrok)
# Test your webhook receiver WITHOUT a real on-ramp payment.
# Do NOT test by paying a tiny amount: anything below an on-ramp's
# minimum (~$20) hits the gateway's "below minimum" error + KYC — that's
# the live on-ramp, not your webhook. Use the signed simulator instead.
# 1. Expose your local webhook endpoint
ngrok http 3000
# 2. Create a TEST session with your sk_test_ key. Amount can be anything
# (the simulator ignores on-ramp minimums). Note the "id" it returns.
curl -X POST 'https://pay.qistdigital.com/api/v1/checkout/init' \
-H "Authorization: Bearer $QIST_TEST_KEY" \
-H 'Content-Type: application/json' \
-d '{
"amount_cents": 5000,
"currency": "EUR",
"customer_email": "test+sandbox@yours.com",
"webhook_url": "https://xxxx.ngrok-free.app/webhooks/qist"
}'
# 3. Fire a SIGNED order.paid straight at your endpoint — no on-ramp, no
# minimum, no KYC. (webhook_url_override also accepts localhost / ngrok
# / loca.lt / trycloudflare.com if you'd rather not set it on the session.)
curl -X POST 'https://pay.qistdigital.com/api/v1/test/fire-webhook' \
-H "Authorization: Bearer $QIST_TEST_KEY" \
-H 'Content-Type: application/json' \
-d '{ "session_id": "PASTE_id_FROM_STEP_2" }'
# 4. The response echoes your receiver's HTTP status + the exact signature
# header we sent, so you can line it up against your HMAC verification.Fehler & Rate Limits
Alle Fehler teilen die Form . Statuscodes sind Standard-REST.
400401403404429502Troubleshooting
- Session ist im Dashboard 'paid', aber mein Webhook hat nie gefeuert
- Prüf, dass webhook_url über öffentliches HTTPS erreichbar ist (curl es von außerhalb deines LANs). Wenn das stimmt, poll GET /sessions/{id} zur Status-Bestätigung — das Dashboard /app zeigt Webhook-Delivery-Stats (Success Rate, Counts). Sechs Versuche über 42h vor Dead-Letter; du kannst immer per Polling re-syncen.
- HMAC-Mismatch — Signatur ist immer invalid
- In 99% der Fälle: du hashst einen neu-serialisierten Body statt der rohen Bytes. Frameworks parsen JSON auto vor deinem Handler; du brauchst den rohen Buffer. Next.js: req.text() vor jedem .json(). Express: app.use('/webhooks', express.raw({ type: '*/*' }), …). Rails: request.raw_post. Check auch, dass du `HMAC(whsec_secret, t + '.' + rawBody)` berechnest — NICHT nur `HMAC(whsec_secret, rawBody)`. Das Timestamp-Prefix ist Pflicht.
- MoonPay sagt 'service unavailable in your country'
- MoonPay sperrt ~20 Länder (Iran, Nordkorea, Kuba, volle Liste auf deren Site). Default-Provider ist 'gateway' — der Smart Picker fällt automatisch auf Revolut, Transak oder Banxa zurück, die andere Regionen abdecken. Wenn du einen spezifischen Provider mit provider: 'moonpay' gepinnt hast, lass es weg und lass den Router wählen.
- Meine Wallet hat nach einem 'paid'-Event kein USDC bekommen
- Prüf polygonscan.com/address/<deine-wallet> auf USDC-(Polygon POS)-Transfers. Settlement landet zu 97% bei dir und zu 3% bei The Desk — wenn du die 97% nicht eingehend siehst, hast du evtl. die falsche Wallet in den Init-Call eingefügt. Re-check via GET /sessions/{id} — das txid-Feld zeigt auf den echten On-Chain-Transfer.
- Kunde wurde doppelt belastet
- Sollte nicht passieren. Jede Session hat eine Settlement-addressIn; eine zweite Zahlung an dieselbe Adresse wird bei uns zu einer separaten Session und wir creditieren nur die erste deiner Bestellung zu. Falls doch, Screenshot der zwei Polygonscan-txids + Session-ID an hi@pay.qistdigital.com — wir refunden das Duplikat aus unserer Treasury.
- Ich bekomme 502 'Payment infrastructure temporarily unavailable'
- Unser Settlement-Upstream ist degraded (< 0.5% der Requests). Retry in 30s mit dem gleichen Idempotency-Key — unser Cache gibt die Original-Response zurück, sobald die Wallet erfolgreich mintet. Track /status für Live-Incidents.
Bereit zu integrieren?
Die meisten Merchants gehen in unter 30 Minuten von Null zur ersten bezahlten Transaktion.