# Issue a new access token Source: https://docs.quidkey.com/api-reference/auth/issue-a-new-access-token /api-reference/openapi.json post /api/v1/oauth2/token Issue a new access token # Refresh an existing access token Source: https://docs.quidkey.com/api-reference/auth/refresh-an-existing-access-token /api-reference/openapi.json post /api/v1/oauth2/refresh Refresh an existing access token # List account balances Source: https://docs.quidkey.com/api-reference/balances/list-account-balances /api-reference/openapi.json get /api/v1/balances List live account balances for a merchant, one entry per account and currency. The account with the refund role holds the funds available for refunds. Omit merchant_id when calling with merchant credentials (resolved from the token); partner and admin callers must supply it. # Retrieve the curated top banks for a market. `country` selects the market; optional `currency` filters further; `limit` caps the result count. Public and cacheable at the market level. Source: https://docs.quidkey.com/api-reference/banks/retrieve-the-curated-top-banks-for-a-market-`country`-selects-the-market;-optional-`currency`-filters-further;-`limit`-caps-the-result-count-public-and-cacheable-at-the-market-level /api-reference/openapi.json get /api/v1/banks/top Retrieve the curated top banks for a market. `country` selects the market; optional `currency` filters further; `limit` caps the result count. Public and cacheable at the market level. # Create a payment request and return a payment_token for iframe flow Source: https://docs.quidkey.com/api-reference/embedded/create-a-payment-request-and-return-a-payment_token-for-iframe-flow /api-reference/openapi.json post /api/v1/embedded/payment-requests Create a payment request and return a payment_token for iframe flow # Initiate a payment for an embedded flow using payment_token (query or body) and selected bank Source: https://docs.quidkey.com/api-reference/embedded/initiate-a-payment-for-an-embedded-flow-using-payment_token-query-or-body-and-selected-bank /api-reference/openapi.json post /api/v1/embedded/payment-initiation Initiate a payment for an embedded flow using payment_token (query or body) and selected bank # Update an embedded payment request (amount and/or rewards) before payment initiation Source: https://docs.quidkey.com/api-reference/embedded/update-an-embedded-payment-request-amount-andor-rewards-before-payment-initiation /api-reference/openapi.json patch /api/v1/embedded/payment-requests Update an embedded payment request (amount and/or rewards) before payment initiation # Issue a new access token Source: https://docs.quidkey.com/api-reference/endpoint/issue-token POST /api/v1/oauth2/token Issue a new access token Exchange your `client_id` and `client_secret` for an access token. The token is valid for 15 minutes and must be included in the `Authorization: Bearer ` header of all subsequent API requests. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/token' \ -H 'Content-Type: application/json' \ -d '{ "client_id": "cbad8f7d-41f5-463d-967c-ca825eb65953", "client_secret": "98c787a7b57a881e469e64579be9823a302185213de0d3835a766f1e3907982f" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: 'cbad8f7d-41f5-463d-967c-ca825eb65953', client_secret: '98c787a7b57a881e469e64579be9823a302185213de0d3835a766f1e3907982f' }) }); const { data } = await response.json(); const { access_token, refresh_token, expires_in } = data; console.log('Access token:', access_token); ``` ```python Python theme={null} import requests response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/token', json={ 'client_id': 'cbad8f7d-41f5-463d-967c-ca825eb65953', 'client_secret': '98c787a7b57a881e469e64579be9823a302185213de0d3835a766f1e3907982f' } ) data = response.json() access_token = data['data']['access_token'] print(f'Access token: {access_token}') ``` **Token Lifecycle:** * **Validity:** 15 minutes (900 seconds) * **Refresh:** Use the `refresh_token` to get a new `access_token` without re-authenticating * **Best practice:** Cache tokens and refresh before expiry Don't have credentials yet? Sign up at [console.quidkey.com](https://console.quidkey.com) to get your `client_id` and `client_secret` for development and production environments. # API Reference Source: https://docs.quidkey.com/api-reference/introduction Technical overview of the Quidkey API ## Base URLs Quidkey provides separate environments for development and production: | Environment | Base URL | Purpose | | ------------ | -------------------------- | ---------------- | | **Base URL** | `https://core.quidkey.com` | All API requests | All endpoints are served under the `/api/v1` prefix (for example `https://core.quidkey.com/api/v1/oauth2/token`). Always use `test_transaction: true` in development to avoid processing real payments. ## Authentication All API endpoints require authentication using **OAuth 2.0 Client Credentials** flow. Sign up at [console.quidkey.com](https://console.quidkey.com) to get your `client_id` and `client_secret` Call `POST /api/v1/oauth2/token` with your credentials to receive an `access_token` Include the token in all API requests: `Authorization: Bearer ` **Token Lifecycle:** * **Validity:** 15 minutes (900 seconds) * **Refresh:** Use `POST /api/v1/oauth2/refresh` with your `refresh_token` * **Best practice:** Cache tokens and refresh before expiry Test the authentication flow in the interactive playground ## API Endpoints The Quidkey API is organized into logical groups: Obtain and refresh access tokens Create, update, and initiate payments Create, list, and manage checkout links Configure webhook endpoints and secrets ## Common Response Format All Quidkey API responses follow a consistent structure: ```json Success Response theme={null} { "success": true, "data": { // Response data here } } ``` ```json Error Response theme={null} { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Human-readable error message", "metadata": { "errors": [ { "field": "amount", "message": "Amount must be greater than 0" } ] } } } ``` ## HTTP Status Codes | Status Code | Description | Common Scenarios | | ----------- | --------------------- | --------------------------------------- | | `200` | Success | Request processed successfully | | `201` | Created | Resource created successfully | | `400` | Bad Request | Validation error or malformed request | | `401` | Unauthorized | Missing or invalid authentication | | `403` | Forbidden | Valid auth but insufficient permissions | | `404` | Not Found | Resource doesn't exist | | `500` | Internal Server Error | Server-side issue (rare) | ## Error Codes Common error codes you may encounter: | Code | Description | Resolution | | ---------------------------- | ------------------------------------- | ------------------------------------------------------- | | `VALIDATION_ERROR` | Request validation failed | Check `error.metadata.errors` for field-specific issues | | `NO_TOKEN` / `INVALID_TOKEN` | Missing or expired token (HTTP `401`) | Refresh your access token; branch on the `401` status | | `MERCHANT_NOT_FOUND` | Invalid merchant ID | Verify your credentials | | `PAYMENT_REQUEST_NOT_FOUND` | Invalid payment token | Check token hasn't expired (15 min TTL) | | `PAYMENT_ALREADY_INITIATED` | Payment already in progress | Cannot update amount after customer selects bank | | `PAYMENT_LINK_NOT_FOUND` | Invalid payment link token or ID | Verify the token or link ID | | `PAYMENT_LINK_NOT_ACTIVE` | Link is used, expired, or cancelled | Check link status before attempting payment | All error responses include a human-readable `message` field. Use `code` for programmatic handling, `message` for logging/debugging. ## Rate Limits Quidkey currently does not enforce strict rate limits. However, we recommend implementing exponential backoff for retry logic and avoiding unnecessary API calls. ## API Versioning The current API version is **v1**, indicated in all endpoint paths: `/api/v1/...` * **Breaking changes:** Will be released as new versions (v2, v3, etc.) * **Non-breaking changes:** Added to existing version without path changes * **Deprecation:** Minimum 6 months notice before removing endpoints ## Getting Started Get your first payment working in 10 minutes Complete embedded payment integration guide ## Need Help? Email [rabea@quidkey.com](mailto:rabea@quidkey.com) - we typically respond within one business day # Create a new payment link (Partner or Merchant authentication) Source: https://docs.quidkey.com/api-reference/payment-links/create-a-new-payment-link-partner-or-merchant-authentication /api-reference/openapi.json post /api/v1/payment-links Create a new payment link (Partner or Merchant authentication) # Get payment link details by ID Source: https://docs.quidkey.com/api-reference/payment-links/get-payment-link-details-by-id /api-reference/openapi.json get /api/v1/payment-links/{id} Get payment link details by ID # List payment links with context-aware access control Source: https://docs.quidkey.com/api-reference/payment-links/list-payment-links-with-context-aware-access-control /api-reference/openapi.json get /api/v1/payment-links List payment links with context-aware access control # Check the status of one of your payment requests by id. Documented fallback for missed webhooks. Source: https://docs.quidkey.com/api-reference/payments/check-the-status-of-one-of-your-payment-requests-by-id-documented-fallback-for-missed-webhooks /api-reference/openapi.json get /api/v1/payment-requests/{paymentRequestId}/status Check the status of one of your payment requests by id. Documented fallback for missed webhooks. # Create a payment request via the redirect (Pay by Bank) flow Source: https://docs.quidkey.com/api-reference/payments/create-a-payment-request-via-the-redirect-pay-by-bank-flow /api-reference/openapi.json post /api/v1/payment-requests:redirect Create a payment request via the redirect (Pay by Bank) flow # Generate a webhook signing secret for the authenticated merchant Source: https://docs.quidkey.com/api-reference/webhook/generate-a-webhook-signing-secret-for-the-authenticated-merchant /api-reference/openapi.json post /api/v1/webhooks/secret Generate a webhook signing secret for the authenticated merchant # Register or update a merchant webhook URL Source: https://docs.quidkey.com/api-reference/webhook/register-or-update-a-merchant-webhook-url /api-reference/openapi.json post /api/v1/webhooks Register or update a merchant webhook URL # Revoke the current webhook signing secret Source: https://docs.quidkey.com/api-reference/webhook/revoke-the-current-webhook-signing-secret /api-reference/openapi.json post /api/v1/webhooks/secret/revoke Revoke the current webhook signing secret # Monitor Your Balances Source: https://docs.quidkey.com/guides/balances/monitoring Read live account balances and alert when the funds available for refunds run low Your Quidkey accounts hold real balances at the payment provider. `GET /api/v1/balances` returns those balances live, one entry per account and currency — so you can monitor how much is available and alert your team when it runs low. The most common use case is **watching the funds available for refunds**: the account with the `refund` role holds the pool customer refunds are paid from. Poll this endpoint on a schedule and raise an alert when that balance drops below your threshold. View balances in your dashboard Full endpoint specification ## Account roles A merchant's provider-managed accounts are each tagged with a role. Balances are returned **per account**, because a merchant can hold several — including more than one currency. A merchant with no managed accounts yet receives an empty `data` array. | Role | Holds | | ----------- | --------------------------------------------------------------------------------------- | | `refund` | The pool customer refunds are paid from — **the "available for refunds" figure** | | `receiving` | Funds that have just landed and are still being processed (in-flight, not yet paid out) | Read the `refund` row to answer "can I cover refunds right now?". The `receiving` balance is money mid-pipeline (about to be converted and paid out) — it is not spendable refund headroom. ## Step 1: Fetch balances Call `GET /api/v1/balances` with your access token. As a merchant you don't pass an ID — the account is resolved from your credentials. Every request needs an `Authorization: Bearer ` header. See [Authentication](/guides/payment-api/concepts/authentication) to exchange your `client_id` and `client_secret` for a token. ```bash cURL theme={null} curl 'https://core.quidkey.com/api/v1/balances' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ```javascript Node.js theme={null} const res = await fetch('https://core.quidkey.com/api/v1/balances', { headers: { Authorization: `Bearer ${accessToken}` }, }); const { data } = await res.json(); ``` ```python Python theme={null} import requests res = requests.get( 'https://core.quidkey.com/api/v1/balances', headers={'Authorization': f'Bearer {access_token}'}, ) data = res.json()['data'] ``` A successful response lists one row per account: ```json theme={null} { "success": true, "data": [ { "id": "a1b2…", "currency": "GBP", "roles": ["refund"], "balance": "1240.50" }, { "id": "c3d4…", "currency": "GBP", "roles": ["receiving"], "balance": "5000.00" }, { "id": "e5f6…", "currency": "EUR", "roles": ["refund"], "balance": null } ] } ``` ### Response fields | Field | Type | Description | | ---------- | -------------- | ------------------------------------------------------------------------------------ | | `id` | string (uuid) | Unique identifier of the account | | `currency` | string | ISO 4217 currency of the account | | `roles` | string\[] | Account roles (`refund`, `receiving`, …) | | `balance` | string \| null | Live decimal balance in the account currency, or `null` when temporarily unavailable | `balance` is `null` when the provider did not report a figure for that account (e.g. a freshly provisioned account, or a transient provider hiccup). `null` means "unknown right now" — it is **not** zero. Don't render it as `0`, and don't fire a low-balance alert on it. ## Step 2: Alert on a low refund balance Pick the `refund` row for the currency you care about and compare it against your threshold: ```javascript Node.js theme={null} const res = await fetch('https://core.quidkey.com/api/v1/balances', { headers: { Authorization: `Bearer ${accessToken}` }, }); const { data } = await res.json(); const THRESHOLD = 500; // GBP const refundGbp = data.find(b => b.currency === 'GBP' && b.roles.includes('refund')); if (refundGbp && refundGbp.balance !== null && Number(refundGbp.balance) < THRESHOLD) { await notifyOps(`Refund balance low: £${refundGbp.balance}`); } ``` ```python Python theme={null} res = requests.get( 'https://core.quidkey.com/api/v1/balances', headers={'Authorization': f'Bearer {access_token}'}, ) data = res.json()['data'] THRESHOLD = 500.0 # GBP refund_gbp = next( (b for b in data if b['currency'] == 'GBP' and 'refund' in b['roles']), None, ) if refund_gbp and refund_gbp['balance'] is not None and float(refund_gbp['balance']) < THRESHOLD: notify_ops(f"Refund balance low: £{refund_gbp['balance']}") ``` Balances are fetched live from the provider on every call. Poll on a sensible cadence (e.g. every few minutes) rather than on every request, and cache the result on your side if you show it in a UI. ## Reading another merchant's balances Merchant credentials resolve to their own account automatically. If you are a **partner or platform** acting across merchants, name the target with `?merchant_id=`: ``` GET /api/v1/balances?merchant_id= ``` As a partner or platform, `merchant_id` is **required** — omitting it returns `400 MERCHANT_ID_REQUIRED`. You can only read merchants you own; anything else returns `404`. ## Next steps See the transactions and fees behind each payout Full endpoint specification # After Payment Source: https://docs.quidkey.com/guides/embedded-flow/after-payment Handle webhooks, verify signatures, process fees, and go live After a customer completes a payment, Quidkey delivers the result to your backend via an HTTPS webhook. This guide covers webhook setup, signature verification, fee processing, and a production QA checklist. ## Redirect Handling After bank authentication, the customer is redirected to the URL you specified when [creating the payment request](/guides/embedded-flow/create): * **Success**: redirected to `success_url` * **Failure / Cancel**: redirected to `failure_url` Do not rely solely on the redirect to confirm payment. Always verify payment status via the webhook. Redirects can fail or be interrupted. ## Webhook Setup Quidkey supports two delivery models: * **Default webhook**: a single per-merchant URL, set via the API below and signed with your merchant secret. It is used for every payment that does not name an endpoint. * **Named webhook endpoints**: multiple destinations, each with its own signing secret and an optional test/live scope. You register them in the Console and route each payment to one or more of them by name. See [Named Webhook Endpoints](#named-webhook-endpoints). ### Default Webhook: Register and Obtain a Signing Secret ```bash theme={null} curl -X POST 'https://core.quidkey.com/api/v1/webhooks' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "webhook_url": "https://api.yoursite.com/webhooks/quidkey" }' ``` The response confirms the URL has been registered. ```bash theme={null} curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` The secret is returned **once**. Store it safely in a secure vault (AWS Secrets Manager, HashiCorp Vault, etc.). See the [Generate Webhook Secret API](/api-reference/webhook/generate-a-webhook-signing-secret-for-the-authenticated-merchant) for complete details and interactive playground. Roll or revoke the secret during incident response: ```bash theme={null} curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret/revoke' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ### Named Webhook Endpoints Platforms that create payments on behalf of several businesses (for example, a travel platform handling payments for multiple airlines) can register **multiple** webhook endpoints, each with its own URL and signing secret, then route each payment to the right one. Endpoints are managed in the Console, not the API. In the [Console](https://console.quidkey.com), switch into the relevant **merchant** context, then open **Settings → Webhooks**. Select **Add endpoint** and provide: * **Name**: a stable, unique identifier you reference per payment (for example `wizzair-prod`). It cannot be changed after creation. * **URL**: the HTTPS or HTTP destination. Private, loopback, link-local, and metadata addresses are rejected. * **Signed**: leave enabled to receive an `X-Signature` header (recommended). Disable only for a destination that cannot verify signatures. * **Environment**: choose **Test**, **Live**, or **Both** (default). A test-only endpoint receives webhooks only for test payments, and a live-only endpoint only for live payments, so you can validate an integration against a test receiver without it ever seeing live traffic. A `whsec_...` secret is generated and shown **once**. Copy it immediately and share it with the destination out of band. The secret is revealed only at creation and on rotation. If it is lost, rotate to issue a new one. * **Rotate secret** issues a new secret and shows it once. The previous secret stops verifying **immediately** (there is no grace window), so coordinate rotation with the destination. * **Disable** stops new deliveries to the endpoint. Endpoints are disabled, never deleted, so a payment that already references one always resolves. #### Route a payment to one or more endpoints Reference endpoints by name when you [create a payment request](/guides/embedded-flow/create), using the optional `webhook_endpoints` field. You can name up to **10** endpoints, and the payment's webhook is **fanned out** to every one of them (for example, the downstream business *and* your own platform receiver): ```json theme={null} { "webhook_endpoints": ["wizzair-prod", "tryp-platform"] } ``` Each endpoint receives its own delivery, signed with **that endpoint's** secret (an unsigned endpoint receives no `X-Signature`). A reference that is unknown, disabled, not owned by you, or whose test/live scope does not match the payment is rejected at creation with `400 UNKNOWN_WEBHOOK_ENDPOINT`. The payment is not created, so fix the reference and retry. #### Resolution order When a payment does **not** name an endpoint, Quidkey resolves the destination in order: 1. The default `webhook_url`, if set (signed with the merchant secret). Unchanged from today, so existing integrations are never re-routed. 2. Otherwise, the merchant's **default endpoint**, if one is marked as default in the Console (signed with its own secret). 3. Otherwise, if the merchant has exactly **one** active endpoint, that endpoint. 4. Otherwise (no default URL, no default endpoint, and zero or multiple active endpoints) the delivery is recorded as `FAILED`. Quidkey never guesses between endpoints; mark a default endpoint, or name one per payment, to avoid this. A payment that names endpoints always uses them. For each named endpoint that is disabled, or whose test/live scope no longer matches the payment, that delivery is recorded as `FAILED` while the others still deliver; there is no fallback to the default. ### Webhook Payload Quidkey sends a Stripe-style envelope so existing tooling can be reused. The example below is the **default webhook** payload. Deliveries to a **named endpoint** use a reduced payload: `payment_token` and `bank_name` are omitted, leaving only `order_id` in `metadata`. ```json theme={null} { "id": "evt_28b2d68f", "object": "event", "created": 1716148300, "type": "quidkey.payment_request.succeeded", "data": { "object": { "id": "pr_782516093", "amount": 2550, "currency": "EUR", "status": "succeeded", "test": true, "metadata": { "order_id": "ORD-123", "payment_token": "ptok_..." }, "fees": { "total_fees": 2.50, "fees_currency": "EUR", "fees_breakdown": [ { "id": "fee_percentage_123", "type": "percentage", "amount": 1.50, "currency": "EUR", "rate_type": "domestic_percent_fee", "rate_value": 1.5, "notes": "1.5% fee on 25.50 EUR" } ] } } } } ``` **Fee information** is only included for successful payments (`status: "succeeded"`). Failed or cancelled payments do not include fees. ### HTTP Headers | Header | Purpose | | ------------- | --------------------------- | | `X-Signature` | `t=,v1=` | | `X-Timestamp` | Unix epoch seconds | | `X-Client-Id` | Your `client_id` | The HMAC is SHA-256 over `"${timestamp}.${raw_body}"`, keyed by your **webhook signing secret**. For a payment routed to a named endpoint, the key is **that endpoint's** secret; an endpoint marked unsigned sends no `X-Signature` header. ### Verify Signatures The `X-Signature` header lets you confirm that the webhook came from Quidkey and that the payload was not tampered with. ```typescript theme={null} const sig = req.get('x-signature'); const event = stripe.webhooks.constructEvent( req.rawBody, sig, process.env.QUIDKEY_WEBHOOK_SECRET ); ``` ```typescript theme={null} import crypto from 'crypto'; function verify(rawBody: Buffer, header: string, secret: string) { const [, ts, v1] = header.match(/^t=(\d+),v1=(.+)$/) || []; const hmac = crypto .createHmac('sha256', secret) .update(`${ts}.${rawBody}`) .digest('hex'); if (hmac !== v1) throw new Error('Invalid signature'); // Optional: check timestamp tolerance (5 min) } ``` ### Process Webhook Events ```typescript theme={null} app.post('/webhooks/quidkey', (req, res) => { const event = stripe.webhooks.constructEvent( req.body, req.headers['x-signature'], webhookSecret ); if (event.type === 'quidkey.payment_request.succeeded') { const payment = event.data.object; // Store payment details await updatePayment(payment.metadata.order_id, { status: payment.status, amount: payment.amount, currency: payment.currency }); // Process fee information if present if (payment.fees) { await storeFeeInformation({ orderId: payment.metadata.order_id, totalFees: payment.fees.total_fees, feesCurrency: payment.fees.fees_currency, feeBreakdown: payment.fees.fees_breakdown }); } // Process reward information if present if (payment.rewards) { await distributeRewards({ orderId: payment.metadata.order_id, extraRewards: payment.rewards.extra_rewards, totalRewards: payment.rewards.total_rewards }); } } res.status(200).send('OK'); }); ``` **Delivery and recovery:** Each event is delivered with a single attempt (15-second timeout). There is **no automatic retry** today: a failed delivery is recorded and can be **manually resent by Quidkey** on request. Reconcile important payments against the API as a backstop, and de-duplicate using the top-level `id` field (a resend reuses it). Durable automatic retries are planned. ## Fee Handling Quidkey automatically calculates and applies fees for successful transactions. Fee information is included in webhook payloads for merchant accounting and billing reconciliation. **Fee types:** * **Percentage fees**: based on transaction amount (e.g., 1.5% of €100 = €1.50) * **Fixed fees**: flat rate per transaction (e.g., €1.00 per transaction) * **Currency-specific**: fees are calculated in the same currency as the transaction Each fee in the `fees_breakdown` array contains: ```json theme={null} { "id": "fee_unique_identifier", "type": "percentage", "amount": 1.50, "currency": "EUR", "rate_type": "domestic_percent_fee", "rate_value": 1.5, "notes": "1.5% fee on 25.50 EUR" } ``` ## QA Checklist Before Going Live * [ ] Serve checkout over HTTPS (wallets such as Apple Pay require it) * [ ] Use live Stripe keys in production mode (if using Stripe alongside) * [ ] Ask Quidkey for your production merchant\_id and iframe URL * [ ] Store webhook secret in secure vault * [ ] Verify that only one payment method can be selected at any time * [ ] Confirm purchase button enables/disables correctly with bank selection * [ ] Test post-purchase redirect flows for success and failure * [ ] Verify dynamic height adjustments work smoothly * [ ] Verify amount updates work before payment initiation * [ ] Confirm updates are blocked after customer selects a bank * [ ] Test error handling for expired tokens * [ ] Ensure iframe refreshes correctly after updates * [ ] Verify webhook signature validation works * [ ] De-duplicate events by `id` and confirm your recovery path (manual resend) for failed deliveries * [ ] Verify successful payments include fee information * [ ] Ensure your system correctly stores fee breakdown data ## Next Steps Back to the Embedded Flow overview Iframe setup, Stripe mutual exclusion, and purchase button routing Full webhook endpoint documentation Collect payments via a hosted checkout page instead # Create a Payment Request Source: https://docs.quidkey.com/guides/embedded-flow/create Authenticate with the API and create a payment token for the checkout iframe Create a payment request to get a `payment_token` that you'll use to render the bank selection iframe on your checkout page. ## Prerequisites * A Quidkey merchant account with `client_id` and `client_secret` * HTTPS enabled on your checkout page Create your merchant account to get your client\_id and client\_secret. ## Step 1: Authenticate Get an access token using your credentials. The token is valid for 15 minutes. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/token' \ -H 'Content-Type: application/json' \ -d '{ "client_id": "your-client-id", "client_secret": "your-client-secret" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: process.env.QUIDKEY_CLIENT_ID, client_secret: process.env.QUIDKEY_CLIENT_SECRET }) }); const { access_token, refresh_token, expires_in } = await response.json(); ``` ```python Python theme={null} import os, requests response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/token', json={ 'client_id': os.getenv('QUIDKEY_CLIENT_ID'), 'client_secret': os.getenv('QUIDKEY_CLIENT_SECRET') } ) data = response.json() access_token = data['access_token'] ``` **What you'll receive:** * `access_token`: include as `Authorization: Bearer ` in subsequent calls * `refresh_token`: call `/oauth2/refresh` to get a new access token without re-posting credentials * `expires_in`: token validity in seconds (typically 900 = 15 minutes) See the [Authentication API reference](/api-reference/endpoint/issue-token) for complete details and interactive playground. ## Step 2: Create a Payment Request Use the access token to create a payment request. This should be called once the customer has confirmed the final price at checkout. Create this request **after** the customer confirms price and options. The payment token has a 15-minute TTL. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/embedded/payment-requests' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "customer": { "name": "John Doe", "email": "john@example.com", "phone": "+4917646793347", "country": "DE" }, "order": { "order_id": "ORD-123456", "amount": 2550, "currency": "EUR", "payment_reference": "Order #3451", "locale": "en-GB", "test_transaction": true }, "redirect_urls": { "success_url": "https://yoursite.com/success", "failure_url": "https://yoursite.com/failure" } }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/embedded/payment-requests', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ customer: { name: 'John Doe', email: 'john@example.com', phone: '+4917646793347', country: 'DE' }, order: { order_id: 'ORD-123456', amount: 2550, // €25.50 in cents currency: 'EUR', payment_reference: 'Order #3451', locale: 'en-GB', test_transaction: true }, redirect_urls: { success_url: 'https://yoursite.com/success', failure_url: 'https://yoursite.com/failure' } }) }); const { payment_token, expires_in } = await response.json(); console.log('Payment token:', payment_token); ``` ```python Python theme={null} response = requests.post( 'https://core.quidkey.com/api/v1/embedded/payment-requests', headers={'Authorization': f'Bearer {access_token}'}, json={ 'customer': { 'name': 'John Doe', 'email': 'john@example.com', 'phone': '+4917646793347', 'country': 'DE' }, 'order': { 'order_id': 'ORD-123456', 'amount': 2550, # €25.50 in cents 'currency': 'EUR', 'payment_reference': 'Order #3451', 'locale': 'en-GB', 'test_transaction': True }, 'redirect_urls': { 'success_url': 'https://yoursite.com/success', 'failure_url': 'https://yoursite.com/failure' } } ) data = response.json() payment_token = data['payment_token'] ``` You'll receive a `payment_token` (valid for 15 minutes) to embed in your iframe. See the [Create Payment Request API](/api-reference/embedded/create-a-payment-request-and-return-a-payment_token-for-iframe-flow) for the complete specification. ### Request Body Reference | Field | Type | Required | Description | | --------------------------- | --------- | -------- | ------------------------------------------------------------------------------- | | `customer.name` | string | Yes | Customer's full name | | `customer.email` | string | Yes | Customer's email address | | `customer.phone` | string | Yes | E.164 format phone number | | `customer.country` | string | Yes | ISO 3166-1 alpha-2 country code | | `order.amount` | integer | Yes | Amount in minor units (cents). `2550` = €25.50 | | `order.currency` | string | Yes | ISO 4217 currency code | | `order.payment_reference` | string | Yes | Reference shown on bank statement | | `order.order_id` | string | No | Your internal order identifier | | `order.locale` | string | No | BCP-47 locale tag. Default: `en` | | `order.test_transaction` | boolean | No | Set `true` for development testing | | `order.rewards` | object | No | Optional loyalty rewards (see below) | | `redirect_urls.success_url` | string | Yes | Where to redirect after successful payment | | `redirect_urls.failure_url` | string | Yes | Where to redirect after failed/cancelled payment | | `webhook_endpoints` | string\[] | No | Route (fan out) this payment's webhooks to up to 10 named endpoints. See below. | **Amount format:** Use minor units (cents). `1000` = €10.00, `2550` = €25.50. This matches Stripe's format exactly. Include a `rewards` object to display loyalty rewards during checkout: ```json theme={null} { "rewards": { "extra_rewards": 150, "total_rewards": 300, "description": "Loyalty bonus + base rewards" } } ``` * `extra_rewards`: reward points for this transaction * `total_rewards`: total points customer will receive (optional) * `description`: text describing the reward (max 255 characters, optional) Rewards are distributed only on successful payment completion via webhook. **Leave this unset for normal use.** When you omit `webhook_endpoints`, the payment's webhooks follow your merchant [resolution order](/guides/embedded-flow/after-payment#resolution-order): your default `webhook_url`, then your default endpoint, then your single active endpoint. Most integrations never set this field. To send a payment's webhooks to specific [named endpoints](/guides/embedded-flow/after-payment#named-webhook-endpoints) instead (for platforms paying out on behalf of several businesses), add `webhook_endpoints` to the request body with up to **10** endpoint names: ```bash theme={null} curl -X POST 'https://core.quidkey.com/api/v1/embedded/payment-requests' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "customer": { "name": "John Doe", "email": "john@example.com", "phone": "+4917646793347", "country": "DE" }, "order": { "order_id": "ORD-123456", "amount": 2550, "currency": "EUR", "payment_reference": "Order #3451" }, "redirect_urls": { "success_url": "https://yoursite.com/success", "failure_url": "https://yoursite.com/failure" }, "webhook_endpoints": ["wizzair-prod"] }' ``` The webhook is fanned out to every named endpoint, each signed with **its own** secret. A reference that is unknown, disabled, not owned by you, or whose test/live scope does not match the payment returns `400 UNKNOWN_WEBHOOK_ENDPOINT`, and the payment is not created. ## Update a Payment Request (Optional) After creating a payment request, you can update the amount and/or rewards **before** the customer initiates payment with their bank. This is useful for dynamic shipping costs, discount codes, or cart modifications. ```bash cURL theme={null} curl -X PATCH 'https://core.quidkey.com/api/v1/embedded/payment-requests' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "payment_token": "ptok_efghijklm...", "amount": 2750 }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/embedded/payment-requests', { method: 'PATCH', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ payment_token: paymentToken, amount: originalAmount + shippingCost }) }); ``` * Amount updates are only allowed while payment status is `pending` * Once the customer clicks a bank and starts payment, only rewards can be updated * Amount updates are blocked after payment initiation ## Next Steps Add the bank selection iframe to your checkout page Handle webhooks, verify signatures, and process fees Full endpoint documentation with interactive playground Back to the overview and integration flow diagram # Embed the Checkout Source: https://docs.quidkey.com/guides/embedded-flow/embed Add the bank selection iframe alongside Stripe and wire up mutual exclusion After [creating a payment request](/guides/embedded-flow/create), embed the Quidkey bank selection iframe on your checkout page alongside your existing Stripe Payment Element. Customers choose between Stripe or Quidkey, and you route the payment based on their selection. ## Add the Iframe Add the iframe using the `payment_token` from the create step. Place it near your Stripe Payment Element so customers see both options. If the Quidkey API call fails, keep your page in its default Stripe-only state. ```html theme={null} ``` Quidkey will automatically predict and pre-select the customer's bank based on their country and information. ### Stripe PaymentIntent Your existing PaymentIntent creation stays exactly as it is. As long as you already render a single Stripe Payment Element, no backend changes are required. ## Dynamic Height The iframe adjusts its height based on available payment methods and open bank lists. **Height calculation:** * **Base**: `payment_method_count x 54px` (includes 1px border per method) * **With drawer open**: Additional 263px when bank selection lists are open * **Standard markets**: 2 payment methods (108px base) * **Enhanced markets**: 3+ payment methods (162px+ base), e.g., Portugal with Multibanco **JavaScript:** ```javascript theme={null} window.addEventListener('message', (event) => { if (event.data.type === 'quidkey-state-update' && event.data.dynamicHeight) { document.documentElement.style.setProperty( '--quidkey-dynamic-height', event.data.dynamicHeight ); } }); ``` **CSS:** ```css theme={null} :root { --quidkey-dynamic-height: 108px; /* Default for 2 methods */ } .quidkey-element-container { position: relative; overflow: hidden; width: 100%; height: var(--quidkey-dynamic-height); min-height: var(--quidkey-dynamic-height); box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.03), 0px 3px 6px rgba(0, 0, 0, 0.02); transition: height 0.35s !important; } .quidkey-element-container iframe { border: none; height: inherit; left: 0; overflow: hidden; position: absolute; top: 0; width: 100%; } ``` ## Handle Bank Selection & Stripe Mutual Exclusion The key behavior is ensuring only one payment method (Stripe **or** Quidkey) is active at a time. Listen for `postMessage` events from the iframe to track the customer's bank selection, and collapse the Stripe Payment Element when Quidkey is active. ```javascript theme={null} // Track which payment method is currently selected let currentSelection = { source: null, method: null }; let currentPaymentScheme = null; window.addEventListener('message', (event) => { if (event.data.type !== 'quidkey-state-update') return; // Update dynamic height if (event.data.dynamicHeight) { document.documentElement.style.setProperty( '--quidkey-dynamic-height', event.data.dynamicHeight ); } // Track payment scheme currentPaymentScheme = event.data.paymentScheme || null; // Handle bank selection const selectedBankId = event.data.selectedBankId; if (selectedBankId) { currentSelection = { source: 'quidkey', method: selectedBankId }; purchaseButton.disabled = false; } // Collapse Stripe when Quidkey is active if (event.data.isListOpen || event.data.isPredictedBankSelected) { if (paymentElement) { paymentElement.collapse(); } } }); ``` When the customer interacts with the Stripe Payment Element again, update `currentSelection` to `{ source: 'stripe', method: null }` using Stripe's `change` event listener. ### postMessage Fields | Field | Type | Description | | ------------------------- | -------------- | --------------------------------------------------------------- | | `type` | string | Always `"quidkey-state-update"` | | `selectedBankId` | string \| null | Selected bank ID (null if deselected) | | `paymentScheme` | string \| null | Payment scheme (e.g., `"SEPA_CREDIT_TRANSFER"`, `"MULTIBANCO"`) | | `dynamicHeight` | string | CSS height value (e.g., `"108px"`, `"371px"`) | | `isListOpen` | boolean | Whether the bank list drawer is open | | `isPredictedBankSelected` | boolean | Whether Quidkey's predicted bank is selected | ## Payment Schemes The iframe supports multiple payment schemes automatically based on the customer's country: * **SEPA**: EU-wide credit transfers * **Faster Payments**: UK instant payments * **Multibanco**: Portuguese payment method **Payment Scheme Values:** * `null` or `undefined`: use default scheme for customer's country * `"MULTIBANCO"`: user explicitly selected Multibanco * `"SEPA_CREDIT_TRANSFER"`: user explicitly selected SEPA * Future schemes like `"PIX"` and `"UPI"` will work automatically ## Route the Purchase Button When the customer clicks your purchase button, route to either Stripe or Quidkey based on their current selection. ```javascript Quidkey Path theme={null} if (currentSelection.source === 'quidkey') { const response = await fetch('/api/quidkey/initiate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bankId: currentSelection.method, paymentToken: quidkeyPaymentToken, paymentScheme: currentPaymentScheme }), }); const result = await response.json(); if (result.success && result.payment_link) { window.location.href = result.payment_link; // Redirect to bank } } ``` ```javascript Stripe Path theme={null} if (currentSelection.source === 'stripe') { const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${window.location.origin}/success.html`, }, }); if (error) { console.error(error.message); } } ``` ### Quidkey Initiation Backend Your backend proxies the payment initiation to Quidkey and returns the bank redirect URL: ```javascript theme={null} app.post('/api/quidkey/initiate', async (req, res) => { const { bankId, paymentToken, paymentScheme } = req.body; const result = await fetch('https://core.quidkey.com/api/v1/embedded/payment-initiation', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ bankId, paymentScheme: paymentScheme || null }) }); const data = await result.json(); res.json(data); }); ``` After the customer authenticates with their bank, they're redirected to your `success_url` or `failure_url`. ## Test with Demo Bank Load the page in your browser. You should see both the Stripe Payment Element and the Quidkey bank picker. Quidkey will predict and pre-select a demo bank in test mode. Click the Quidkey bank option. The Stripe Payment Element should collapse. Your purchase button should be enabled. Click Purchase, authenticate with any credentials (demo mode), and verify you're redirected to your success URL. Reload and interact with the Stripe Payment Element instead. The Quidkey selection should clear. Confirm the Stripe payment flow works as before. Check your webhook endpoint (if configured) for the Quidkey payment confirmation. ## Reference Implementation Try the full Stripe + Quidkey integration with test credentials Test API calls in your browser. No setup required ## Next Steps Handle webhooks, verify signatures, and process fees Payment request creation and updates # Embedded Flow (with Stripe) Source: https://docs.quidkey.com/guides/embedded-flow/overview Add Quidkey bank payments alongside your existing Stripe Payment Element The Embedded Flow adds Quidkey's bank payment option alongside your existing Stripe Payment Element. Customers see both payment methods on your checkout page and choose between card payments (Stripe) or bank transfers (Quidkey), all without leaving your site. This guide assumes you already have a **Stripe Payment Element** integrated on your checkout page. You're adding Quidkey alongside Stripe, not replacing it. Authenticate and create a payment request to get a payment token Add the bank selection iframe and wire up Stripe mutual exclusion Handle webhooks, verify signatures, and process fees Test creating a payment request in your browser. No setup required ## Prerequisites * [ ] **Stripe Payment Element already integrated** on your checkout page * [ ] Quidkey `client_id` and `client_secret` ([sign up](https://console.quidkey.com)) * [ ] HTTPS enabled on your checkout page **No Stripe changes required.** Your existing Stripe integration stays exactly as it is. You're adding Quidkey alongside, not replacing anything. ## When to Use the Embedded Flow | | **Embedded Flow (with Stripe)** | **Payment Links** | | ----------------------- | ------------------------------------------------------------ | ---------------------------------------------------- | | **Best for** | Merchants with an existing Stripe checkout | Invoicing, ad-hoc payments, no-code scenarios | | **Integration effort** | Embed iframe, handle postMessage events, route Pay button | One API call to create a link, then share the URL | | **Customer experience** | Inline checkout on your site (Stripe + Quidkey side by side) | Quidkey-hosted checkout page | | **Frontend code** | HTML/JavaScript for iframe + Stripe mutual exclusion | None | | **Use case** | E-commerce with Stripe, subscription platforms | B2B invoices, service payments, cross-border pay-ins | **Need both?** You can use the Embedded Flow for your checkout page and Payment Links for invoice emails. They share the same backend API and webhook infrastructure. ## How It Works ```mermaid theme={null} sequenceDiagram autonumber actor Customer participant Merchant as Merchant (frontend) participant Backend as Merchant (server) participant Quidkey Customer->>Merchant: Browse & fill checkout Merchant->>Backend: Finalise price Backend->>Quidkey: POST /embedded/payment-requests Quidkey-->>Backend: { payment_token } Backend-->>Merchant: Render checkout + inject iframe Merchant->>Quidkey: GET /embedded (iframe bootstrap) Quidkey-->>Merchant: Bank picker HTML opt Dynamic Updates (shipping, discounts, etc.) Customer->>Merchant: Change shipping/apply code Merchant->>Backend: Calculate new total Backend->>Quidkey: PATCH /embedded/payment-requests Quidkey-->>Backend: { updated amount/rewards } Backend-->>Merchant: Update confirmed Merchant->>Merchant: Refresh iframe & UI end Customer->>Merchant: Click preferred bank Merchant->>Merchant: postMessage { bankId, paymentScheme } Merchant->>Backend: POST /embedded/payment-initiation Backend->>Quidkey: POST /payment-initiations Quidkey-->>Backend: { payment_link } Backend-->>Merchant: { payment_link } Merchant-->>Customer: Redirect to bank (SCA) Customer->>Quidkey: Authenticate & approve payment alt Success Quidkey-->>Customer: Redirect to success_url else Failure / cancel Quidkey-->>Customer: Redirect to failure_url end Quidkey-->>Backend: Webhook notification ``` ## Key Features * **Works alongside Stripe**: customers choose between card (Stripe) or bank transfer (Quidkey) on the same checkout page * **Mutual exclusion**: when a customer selects Quidkey, the Stripe Payment Element collapses, and vice versa * **Bank prediction**: Quidkey automatically predicts and pre-selects the customer's bank * **Multiple payment schemes**: SEPA, Faster Payments, Multibanco, and more supported automatically * **Dynamic amounts**: update the payment amount after creation (e.g., shipping costs, discounts) * **Dynamic height**: iframe adjusts height automatically based on available payment methods * **Rewards**: optional loyalty rewards displayed to customers during checkout * **Per-payment webhook routing**: fan a payment's webhooks out to one or more named endpoints, each with its own signing secret and test/live scope, ideal for platforms sending on behalf of multiple businesses ## Next Steps Follow the [Create a Payment Request](/guides/embedded-flow/create) guide to authenticate and get a payment token. Learn how to [embed the bank selection iframe](/guides/embedded-flow/embed) on your checkout page, including Stripe mutual exclusion. Set up [webhooks and fee processing](/guides/embedded-flow/after-payment) to complete your integration. # Onboarding Source: https://docs.quidkey.com/guides/onboarding/overview Create your Quidkey account, complete verification, and get approved to accept live payments This guide explains how to create your Quidkey account and provide the information required for compliance approval, so you can enable Pay by Bank in your checkout. Most merchants complete Steps 1 to 3 in about 10 to 15 minutes, plus additional time for identity verification. ## What you'll need * Legal company name and registration number * Jurisdiction of formation and incorporation date * Registered office and operating addresses * Tax ID and VAT number, where your country requires them * Shareholders with over 25% ownership * Company directors * Photo ID for the people who verify their identity * Your business bank account details ## Step 1: Create your Quidkey account Go to [console.quidkey.com/signup](https://console.quidkey.com/signup) and enter your first name, last name and work email. You can also sign up with Google. Quidkey doesn't use passwords. You sign in with a one-time code sent to your email, or with Google. Quidkey signup page We send a 6-character code to your email address. Enter it to confirm your address and continue. On the **Get your business started with Quidkey** screen, provide: * **Brand name** as your customers know it * **Store URL** * The **industry** you operate in Then confirm that you're an authorised representative of the business, accept Quidkey's Merchant Terms and Privacy Notice, and authorise Quidkey to transact with our banking partner on your behalf. Click **Complete Sign Up**. Your dashboard shows a **Go Live** checklist with the remaining steps. You can complete them in any order. Quidkey Dashboard with the Go Live checklist ## Step 2: Complete your business information Click **Complete business information** on your dashboard and provide your company's legal structure and registration details: jurisdiction of formation, company structure, legal company name, company number, incorporation date, registered office and operating addresses, and a contact for the business. Tax ID and VAT number are requested where your country requires them. If you trade as a sole trader rather than a company, you'll be asked for your personal details, citizenship and residence instead. Click **Add shareholders & directors**. Add details for all shareholders with over 25% ownership, and: * all company directors, if you're incorporated in **Australia** * at least one director, if you're incorporated in the **UK, EU or US** For each person we need their full name, date of birth, email, phone number, country of citizenship, residential address, and ownership percentage for shareholders. You have two options for each person: * **Invite** sends them an email link so they can complete their own details * **Add** lets you enter their details yourself A director who holds no shares can be added with 0% ownership. Anyone recorded as a shareholder needs an ownership share above 0%. One shareholder and one director must verify their identity through our verification partner, Onfido. You'll receive a verification link once the required details are submitted. If you entered someone's information on their behalf, forward the link to them. ## Step 3: Add a bank account Click **Add a bank account** on your dashboard, or open the **Bank Accounts** tab, and enter your business bank account details. Payouts are sent to this account. The details we ask for depend on the country your account is held in: | Country | Details required | | --------------------------------------------------------- | -------------------------------------------------- | | Eurozone (AT, BE, DE, EE, ES, FI, FR, IE, IT, LT, NL, PT) | IBAN, and optionally BIC | | United Kingdom | Sort code (6 digits) and account number (8 digits) | | Denmark | Reg. number (4 digits) and account number | | Australia | BSB (6 digits) and account number | | United States | Routing number (9 digits) and account number | Every account also needs the account holder name, currency and country. The account holder name must match the legal entity you registered. Payouts to an account held in a different name will be rejected by the receiving bank. ## Step 4: Compliance approval Once Steps 1 to 3 are complete, Quidkey submits your business information to our banking partners for compliance review. Your dashboard shows that your information is being reviewed, and we'll notify you once it has been approved. Once approved: * Live payments are enabled on your account * Payouts are activated * Your pricing is applied When every checklist item is complete and approved, your dashboard shows **You're Live!** and you can start accepting real payments. ## Step 5: Add Pay by Bank to your checkout Choose the integration that fits how you sell. Install and connect our Shopify app in a few clicks. No code required, and you can run a test payment before approval Share a link and collect a payment. Ideal for invoices and one-off charges Add Pay by Bank alongside your existing Stripe checkout Build directly against our API for full control over the payment flow ### Find your API credentials Every integration except Shopify's one-click connect needs API credentials. In the Console, open the **Credentials** tab to see your **Client ID** and generate a **Client secret**. Your client secret is shown **only once**. Copy it straight away and store it somewhere safe. If you lose it, revoke the old secret and generate a new one. ## FAQ Usually minutes once every detail and identity verification is submitted. Approval can take longer if a document is unclear or information is missing, in which case we'll come back to you for the missing piece. Yes. You can install and configure the Shopify app and run a full test payment in Shopify test mode without completing verification. Approval is only required before you accept real payments and receive payouts. Anyone holding over 25% ownership of the business, whether an individual or another company. If a company holds the shares, you'll be asked for that company's details rather than a person's. Yes. Use **Invite** instead of **Add** and they'll receive an email link to a page where they can enter their own information and complete identity verification, without needing a Console account. Your bank account is part of the information our banking partners review, and it's where your payouts are sent. Adding it up front means payouts are active the moment you're approved. Add the account anyway. We'll collect the identifiers your account can carry and get in touch if we need anything else to route your payouts. Contact [support@quidkey.com](mailto:support@quidkey.com) if you'd like to confirm coverage before you sign up. ## Support Questions? We're here to help: * Email: [support@quidkey.com](mailto:support@quidkey.com) * Slack: We can create a shared channel for ongoing communication * Video calls: Contact [support@quidkey.com](mailto:support@quidkey.com) to schedule one # Embedded (with Stripe) Source: https://docs.quidkey.com/guides/payment-api/accept-a-payment/embedded Add Quidkey bank payments inline alongside your existing Stripe Payment Element The Embedded flow renders Quidkey's bank payment option next to your Stripe Payment Element. Buyers choose between card (Stripe) and bank transfer (Quidkey) without leaving your site. When the buyer picks one method, the other collapses. This page is a quick orientation. If you're not already on Stripe, start with the [Redirect flow](/guides/payment-api/accept-a-payment/redirect). The full Embedded walkthrough lives in the dedicated [Embedded Flow guide](/guides/embedded-flow/overview). This flow assumes you already run a **Stripe Payment Element** on your checkout. You're adding Quidkey alongside it, not replacing it. No changes to your Stripe code are required. **Amounts are integer minor units.** `2550` = €25.50. If your checkout already passes amounts in minor units, your existing amount handling carries over unchanged. See [Amounts & Currencies](/guides/payment-api/concepts/amounts-and-currencies). Set `test_transaction: true` in the `order` while developing so no real money moves. See [Testing](/guides/payment-api/concepts/testing). ## How It Works You create a payment request from your backend to get a short-lived `payment_token`, render the Quidkey iframe with that token next to your Stripe element, and wire up mutual exclusion so only one method is active at a time. When the buyer initiates payment, they're sent to their bank to approve, and Quidkey confirms the result via webhook. ```mermaid theme={null} sequenceDiagram autonumber actor Buyer participant Merchant as Merchant (frontend) participant Backend as Merchant (server) participant Quidkey Backend->>Quidkey: POST /embedded/payment-requests Quidkey-->>Backend: { payment_token, expires_in } Backend-->>Merchant: Render checkout + inject iframe Buyer->>Merchant: Choose Quidkey (Stripe collapses) Merchant->>Backend: POST /embedded/payment-initiation Backend->>Quidkey: Initiate payment Quidkey-->>Buyer: Redirect to bank (SCA) Quidkey-->>Backend: Webhook (authoritative result) ``` ## Create a Payment Request Call `POST /api/v1/embedded/payment-requests` once the buyer has confirmed the final price. You get back a `payment_token` (valid for 15 minutes) to render in the iframe. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/embedded/payment-requests' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "customer": { "name": "John Doe", "email": "john@example.com", "phone": "+4917646793347", "country": "DE" }, "order": { "order_id": "ORD-123456", "amount": 2550, "currency": "EUR", "payment_reference": "Order3451", "locale": "en-GB", "test_transaction": true }, "redirect_urls": { "success_url": "https://yoursite.com/success", "failure_url": "https://yoursite.com/failure" } }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/embedded/payment-requests', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ customer: { name: 'John Doe', email: 'john@example.com', phone: '+4917646793347', country: 'DE' }, order: { order_id: 'ORD-123456', amount: 2550, // €25.50 in minor units currency: 'EUR', payment_reference: 'Order3451', locale: 'en-GB', test_transaction: true }, redirect_urls: { success_url: 'https://yoursite.com/success', failure_url: 'https://yoursite.com/failure' } }) }); const { data } = await response.json(); const { payment_token, expires_in } = data; ``` ```python Python theme={null} import requests response = requests.post( 'https://core.quidkey.com/api/v1/embedded/payment-requests', headers={'Authorization': f'Bearer {access_token}'}, json={ 'customer': { 'name': 'John Doe', 'email': 'john@example.com', 'phone': '+4917646793347', 'country': 'DE' }, 'order': { 'order_id': 'ORD-123456', 'amount': 2550, # €25.50 in minor units 'currency': 'EUR', 'payment_reference': 'Order3451', 'locale': 'en-GB', 'test_transaction': True }, 'redirect_urls': { 'success_url': 'https://yoursite.com/success', 'failure_url': 'https://yoursite.com/failure' } } ) data = response.json()['data'] payment_token = data['payment_token'] ``` ```json Response theme={null} { "success": true, "data": { "payment_token": "ptok_…", "expires_in": 900 } } ``` The response is enveloped: read `payment_token` and `expires_in` from `data`. Use the token to render the bank selection iframe. Need to change the total after creating the request, for shipping or a discount code? Call `PATCH /api/v1/embedded/payment-requests` to update the amount or rewards before the buyer initiates payment. The full guide covers this in detail. ## After Payment When the buyer approves at their bank, Quidkey confirms the outcome with a signed [webhook](/guides/payment-api/concepts/webhooks) to your backend: this is the source of truth, not any browser redirect. Verify the signature, then fulfil on `quidkey.payment_request.succeeded`. The [Embedded Flow after-payment guide](/guides/embedded-flow/after-payment) covers handling `postMessage` events, signature verification, and processing fees end to end. ## Full Integration Guide This page is a quick orientation. Embedding the iframe, wiring up Stripe mutual exclusion, handling `postMessage` events, calling `POST /api/v1/embedded/payment-initiation`, and processing webhooks are all covered step by step in the dedicated Embedded Flow guide. The complete walkthrough: create, embed, mutual exclusion, and after-payment Authenticate, create the token, and update the amount or rewards Add the iframe and wire up Stripe mutual exclusion Handle webhooks, verify signatures, and process fees ## Other Ways to Accept a Payment No frontend checkout to build: create a payment and redirect the buyer Share a checkout link, no frontend code at all # Hosted Checkout Source: https://docs.quidkey.com/guides/payment-api/accept-a-payment/hosted-checkout Generate a shareable checkout URL in one API call. No frontend code required Collect a bank payment without building any checkout UI. Create a payment link from your backend, share the URL over email, SMS, or any messaging channel, and Quidkey hosts the checkout page. Use it for invoicing and ad-hoc payments. **Amounts are integer minor units.** `5000` = €50.00. The same format is used across the Payment API. See [Amounts & Currencies](/guides/payment-api/concepts/amounts-and-currencies). Set `test_transaction: true` while developing so no real money moves. See [Testing](/guides/payment-api/concepts/testing). ## How It Works You create a link with one API call and get back a `payment_link_url`. Share it however you like. When the buyer opens the link, Quidkey shows a hosted checkout page where they enter their details, pick their bank, and approve the payment. Your backend learns the result via webhook. ```mermaid theme={null} sequenceDiagram autonumber actor Merchant participant Quidkey actor Buyer participant Checkout as Hosted Checkout participant Bank as Buyer's Bank Merchant->>Quidkey: POST /payment-links (amount, currency, reference) Quidkey-->>Merchant: { payment_link_url, expires_at, status } Merchant->>Buyer: Share link (email, SMS, chat) Buyer->>Checkout: Open the link Checkout->>Quidkey: GET /payment-links/:token/details Buyer->>Bank: Authenticate & approve Bank-->>Quidkey: Payment result Quidkey-->>Merchant: Webhook (authoritative result) ``` ## Create a Checkout Link Call `POST /api/v1/payment-links` with the payment details. You get back a `payment_link_url` to share, along with `expires_at` and the link `status`. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-links' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "order": { "amount": 5000, "currency": "EUR", "payment_reference": "INV2024001", "test_transaction": true } }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/payment-links', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ order: { amount: 5000, // €50.00 in minor units currency: 'EUR', payment_reference: 'INV2024001', test_transaction: true } }) }); const { data } = await response.json(); console.log('Payment link URL:', data.payment_link_url); console.log('Expires at:', data.expires_at); console.log('Status:', data.status); ``` ```python Python theme={null} import requests response = requests.post( 'https://core.quidkey.com/api/v1/payment-links', headers={'Authorization': f'Bearer {access_token}'}, json={ 'order': { 'amount': 5000, # €50.00 in minor units 'currency': 'EUR', 'payment_reference': 'INV2024001', 'test_transaction': True } } ) data = response.json()['data'] print('Payment link URL:', data['payment_link_url']) print('Expires at:', data['expires_at']) print('Status:', data['status']) ``` ```json Response theme={null} { "success": true, "data": { "payment_link_url": "https://core.quidkey.com/payment-link/a1b2c3d4e5f6...", "expires_at": "2024-04-07T12:00:00.000Z", "status": "active" } } ``` Save the `payment_link_url`. This is the URL you'll share with your buyer. The token in the URL is only returned once, at creation time. Under the hood, the hosted page reads the link via the public `GET /api/v1/payment-links/:token/details` and completes payment with `POST /api/v1/payment-links/:token/confirm`. You don't call these yourself: Quidkey's checkout page handles them. ## Check the Link Status Read a link's current `status` and `expires_at` at any time with `GET /api/v1/payment-links/{id}`. Use it to reconcile: for example to confirm a link is still active before re-sending it, or as a backstop if you expected a webhook but never received one. ```bash cURL theme={null} curl 'https://core.quidkey.com/api/v1/payment-links/PAYMENT_LINK_ID' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ```javascript Node.js theme={null} const response = await fetch( `https://core.quidkey.com/api/v1/payment-links/${paymentLinkId}`, { headers: { 'Authorization': `Bearer ${accessToken}` } } ); const { data } = await response.json(); console.log('Status:', data.status); console.log('Expires at:', data.expires_at); ``` ```python Python theme={null} response = requests.get( f'https://core.quidkey.com/api/v1/payment-links/{payment_link_id}', headers={'Authorization': f'Bearer {access_token}'} ) data = response.json()['data'] print('Status:', data['status']) print('Expires at:', data['expires_at']) ``` The link status is for managing the link itself. The authoritative record of whether the buyer paid is the [webhook](/guides/payment-api/concepts/webhooks): fulfil orders on `quidkey.payment_request.succeeded`, not on the link status. ## Full Integration Guide This page is a quick orientation. Sharing strategies, the checkout experience, custom redirect URLs, link expiry, single-use versus reusable links, and webhooks are all covered in the dedicated Hosted Checkout guide. The complete walkthrough, including the link lifecycle Full request reference, custom redirect URLs, and link expiry See what your buyers see when they open a link Track status, handle webhooks, and manage your links ## Other Ways to Accept a Payment Create a payment and redirect the buyer to a Quidkey-hosted bank page Add Quidkey inline alongside your Stripe Payment Element # Redirect (Pay by Bank) Source: https://docs.quidkey.com/guides/payment-api/accept-a-payment/redirect Create a payment and redirect the buyer to a Quidkey-hosted bank page Collect the buyer's details on your own site, create a payment from your backend, and send the buyer to a Quidkey-hosted page to pick their bank and approve. No iframe and no checkout UI to build: one API call and a redirect. **Amounts are integer minor units.** `2550` = £25.50, `1000` = €10.00. The same format is used across the Payment API. See [Amounts & Currencies](/guides/payment-api/concepts/amounts-and-currencies). ## How It Works ```mermaid theme={null} sequenceDiagram autonumber actor Buyer participant Merchant as Merchant (frontend) participant Backend as Merchant (server) participant Quidkey participant Bank as Buyer's Bank Buyer->>Merchant: Confirm order & details Merchant->>Backend: Place order Backend->>Quidkey: POST /payment-requests:redirect (+ Idempotency-Key) Quidkey-->>Backend: 201 { data: { redirect_url } } Backend-->>Buyer: Redirect to redirect_url Buyer->>Quidkey: Pick bank on hosted page Buyer->>Bank: Authenticate & approve (SCA) alt Success Quidkey-->>Buyer: Redirect to success_url_redirect else Failure / cancel Quidkey-->>Buyer: Redirect to fail_url_redirect end Quidkey-->>Backend: Webhook (authoritative result) ``` ## Prerequisites * A Quidkey merchant account with `client_id` and `client_secret` ([sign up](https://console.quidkey.com)) * An access token (see [Authentication](/guides/payment-api/concepts/authentication)) * The buyer's name, email, phone number, and billing address ## Step 1: Authenticate Exchange your credentials for an access token. The token is valid for 15 minutes. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/token' \ -H 'Content-Type: application/json' \ -d '{ "client_id": "your-client-id", "client_secret": "your-client-secret" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: process.env.QUIDKEY_CLIENT_ID, client_secret: process.env.QUIDKEY_CLIENT_SECRET }) }); const { data } = await response.json(); const accessToken = data.access_token; ``` ```python Python theme={null} import os, requests response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/token', json={ 'client_id': os.getenv('QUIDKEY_CLIENT_ID'), 'client_secret': os.getenv('QUIDKEY_CLIENT_SECRET') } ) data = response.json()['data'] access_token = data['access_token'] ``` See the [Authentication API reference](/api-reference/endpoint/issue-token) for the full token lifecycle and an interactive playground. ## Step 2: Create a Redirect Payment Call `POST /api/v1/payment-requests:redirect` with a Bearer token and an `Idempotency-Key`. The request body carries the buyer, the billing address, the amount, and where to send the buyer afterwards. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Idempotency-Key: order-1001-attempt-1' \ -H 'Content-Type: application/json' \ -d '{ "merchant_id": "your-merchant-id", "customer": { "name": "Jane Buyer", "email": "jane@example.com", "phone_number": "+447700900123" }, "billing_address": { "address_line1": "1 Market Street", "city": "London", "postal_code": "EC1A 1AA", "country": "GB" }, "amount": 2550, "currency": "GBP", "payment_reference": "ORDER1001", "order_id": "1001", "locale": "en-GB", "success_url_redirect": "https://yoursite.com/success", "fail_url_redirect": "https://yoursite.com/failure", "test_transaction": true }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/payment-requests:redirect', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Idempotency-Key': 'order-1001-attempt-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ merchant_id: process.env.QUIDKEY_MERCHANT_ID, customer: { name: 'Jane Buyer', email: 'jane@example.com', phone_number: '+447700900123' }, billing_address: { address_line1: '1 Market Street', city: 'London', postal_code: 'EC1A 1AA', country: 'GB' }, amount: 2550, // £25.50 in minor units currency: 'GBP', payment_reference: 'ORDER1001', order_id: '1001', locale: 'en-GB', success_url_redirect: 'https://yoursite.com/success', fail_url_redirect: 'https://yoursite.com/failure', test_transaction: true }) }); const { data } = await response.json(); res.redirect(303, data.redirect_url); ``` ```python Python theme={null} import os, requests response = requests.post( 'https://core.quidkey.com/api/v1/payment-requests:redirect', headers={ 'Authorization': f'Bearer {access_token}', 'Idempotency-Key': 'order-1001-attempt-1' }, json={ 'merchant_id': os.getenv('QUIDKEY_MERCHANT_ID'), 'customer': { 'name': 'Jane Buyer', 'email': 'jane@example.com', 'phone_number': '+447700900123' }, 'billing_address': { 'address_line1': '1 Market Street', 'city': 'London', 'postal_code': 'EC1A 1AA', 'country': 'GB' }, 'amount': 2550, # £25.50 in minor units 'currency': 'GBP', 'payment_reference': 'ORDER1001', 'order_id': '1001', 'locale': 'en-GB', 'success_url_redirect': 'https://yoursite.com/success', 'fail_url_redirect': 'https://yoursite.com/failure', 'test_transaction': True } ) data = response.json()['data'] redirect_url = data['redirect_url'] ``` ### Response ```json theme={null} { "success": true, "data": { "redirect_url": "https://core.quidkey.com/redirect/9f8c7b6a5e4d..." } } ``` A successful call returns **201 Created**. Save the `redirect_url`: it's the bank page you'll send the buyer to next. ### Request Body Reference | Field | Type | Required | Description | | ------------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- | | `merchant_id` | string | No | UUID of the merchant collecting the payment. Optional for merchant tokens; required only for admin/global tokens | | `customer.name` | string | Yes | Buyer's full name | | `customer.email` | string | Yes | Buyer's email address | | `customer.phone_number` | string | No | E.164 format phone number | | `billing_address.address_line1` | string | Yes | First line of the billing address | | `billing_address.city` | string | Yes | City | | `billing_address.postal_code` | string | Yes | Postal or ZIP code | | `billing_address.country` | string | Yes | ISO 3166-1 alpha-2 country code | | `amount` | integer | Yes | Amount in minor units (`2550` = £25.50). Min `1`, max `9999999` (≈ £99,999.99) | | `currency` | string | Yes | ISO 4217 currency code (e.g., `GBP`, `EUR`) | | `locale` | string | Yes | BCP-47 locale tag for the hosted page (e.g., `en-GB`) | | `payment_reference` | string | No | Max 18 characters, alphanumeric only (`[A-Za-z0-9]`). Shown on the buyer's bank statement. | | `order_id` | string | No | Your internal order identifier for reconciliation | | `success_url_redirect` | string | No | Where to send the buyer after a successful payment | | `fail_url_redirect` | string | No | Where to send the buyer after a failed or cancelled payment | | `selected_bank_id` | string | No | Pre-select a bank and skip the picker. See [Deep-link to a bank](#deep-link-to-a-bank) | | `test_transaction` | boolean | No | Set `true` in development so no real money moves | **Strict schema.** This endpoint rejects unknown fields and rejects decimal amounts. Send only the fields above, and send `amount` as a whole integer in minor units. `25.50` is invalid; `2550` is correct. ## Step 3: Redirect the Buyer Send the buyer's browser to the `redirect_url`. It opens a Quidkey-hosted page that shows **only banks** (no card option), where the buyer selects their bank and approves the payment inside their banking app or web flow. When they finish, Quidkey returns them to your `success_url_redirect` or `fail_url_redirect`. ```javascript Node.js theme={null} // In your route handler, after creating the payment: res.redirect(303, data.redirect_url); ``` The browser redirect back to your site only means the buyer **returned**. It does not confirm the payment settled. Always wait for the webhook before fulfilling the order. See [Verify with webhooks](#verify-with-webhooks). After the bank flow, Quidkey sends the buyer back to your `success_url_redirect` or `fail_url_redirect`. Treat these strictly as **UX**: a place to show the buyer a confirmation or retry screen. They are **not** proof of the outcome. Confirm the result authoritatively via the [webhook](/guides/payment-api/concepts/webhooks), or by polling the merchant status endpoint `GET /api/v1/payment-requests/{paymentRequestId}/status`. ## Idempotency Send a unique `Idempotency-Key` header on every create request. If the request is retried, for example after a network timeout, Quidkey returns the original result instead of creating a second payment. Use a value tied to the buyer's intent, such as your order ID plus an attempt counter. Reuse the **same** key when retrying the **same** logical request. Use a **new** key only when the buyer genuinely starts a new payment. See [Idempotency](/guides/payment-api/concepts/idempotency) for the full semantics. ## Deep-link to a Bank To skip the bank picker, pass a `selected_bank_id` at create time when the buyer has already chosen their bank in your own UI. The hosted page takes them straight to that bank. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Idempotency-Key: order-1001-attempt-1' \ -H 'Content-Type: application/json' \ -d '{ "merchant_id": "your-merchant-id", "customer": { "name": "Jane Buyer", "email": "jane@example.com", "phone_number": "+447700900123" }, "billing_address": { "address_line1": "1 Market Street", "city": "London", "postal_code": "EC1A 1AA", "country": "GB" }, "amount": 2550, "currency": "GBP", "locale": "en-GB", "selected_bank_id": "38c39d03-8df3-4980-b0a8-7e283c8c62dd", "test_transaction": true }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/payment-requests:redirect', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Idempotency-Key': 'order-1001-attempt-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ merchant_id: process.env.QUIDKEY_MERCHANT_ID, customer: { name: 'Jane Buyer', email: 'jane@example.com', phone_number: '+447700900123' }, billing_address: { address_line1: '1 Market Street', city: 'London', postal_code: 'EC1A 1AA', country: 'GB' }, amount: 2550, currency: 'GBP', locale: 'en-GB', selected_bank_id: '38c39d03-8df3-4980-b0a8-7e283c8c62dd', test_transaction: true }) }); ``` ```python Python theme={null} response = requests.post( 'https://core.quidkey.com/api/v1/payment-requests:redirect', headers={ 'Authorization': f'Bearer {access_token}', 'Idempotency-Key': 'order-1001-attempt-1' }, json={ 'merchant_id': os.getenv('QUIDKEY_MERCHANT_ID'), 'customer': { 'name': 'Jane Buyer', 'email': 'jane@example.com', 'phone_number': '+447700900123' }, 'billing_address': { 'address_line1': '1 Market Street', 'city': 'London', 'postal_code': 'EC1A 1AA', 'country': 'GB' }, 'amount': 2550, 'currency': 'GBP', 'locale': 'en-GB', 'selected_bank_id': '38c39d03-8df3-4980-b0a8-7e283c8c62dd', 'test_transaction': True } ) ``` ### Build a Bank Button To show the buyer's most popular banks in your own UI before they reach the hosted page, fetch the top banks for their country and currency, then pass the chosen `id` as `selected_bank_id`. This endpoint is public and cacheable at the market level, so it needs no access token. ```bash cURL theme={null} curl 'https://core.quidkey.com/api/v1/banks/top?country=GB¤cy=GBP&limit=3' ``` ```javascript Node.js theme={null} const response = await fetch( 'https://core.quidkey.com/api/v1/banks/top?country=GB¤cy=GBP&limit=3' ); const { data } = await response.json(); for (const bank of data.banks) { console.log(bank.id, bank.displayName, bank.logoUrl); } ``` ```python Python theme={null} response = requests.get( 'https://core.quidkey.com/api/v1/banks/top', params={'country': 'GB', 'currency': 'GBP', 'limit': 3} ) for bank in response.json()['data']['banks']: print(bank['id'], bank['displayName'], bank['logoUrl']) ``` ```json Response theme={null} { "success": true, "data": { "banks": [ { "id": "38c39d03-8df3-4980-b0a8-7e283c8c62dd", "displayName": "Example Bank", "logoUrl": "https://img.logo.dev/example-bank.com" } ] } } ``` `country` is required (ISO 3166-1 alpha-2); `currency` and `limit` (1-50) are optional. Render buttons from `displayName` and `logoUrl`, then pass the matching `id` as `selected_bank_id`. This step is optional: let the buyer choose on the hosted page if you prefer. ## Verify with Webhooks The webhook is the authoritative record of what happened. When a payment reaches a final state, Quidkey sends an event to your registered endpoint: | Event | Meaning | | ----------------------------------- | ----------------------------------------- | | `quidkey.payment_request.succeeded` | Payment completed. Fulfil the order. | | `quidkey.payment_request.failed` | Payment failed at the bank. | | `quidkey.payment_request.canceled` | Buyer abandoned or cancelled the payment. | | `quidkey.payment_request.pending` | Payment is in progress, not yet final. | | `quidkey.payment_request.reversed` | A completed payment was later reversed. | Fulfil orders on `quidkey.payment_request.succeeded`, not on the browser redirect. The redirect can be interrupted; the webhook cannot. Register your endpoint, verify signatures, and handle every payment status event ## Next Steps The condensed end-to-end version of this flow Prefer an inline checkout alongside Stripe? Use the Embedded flow Just need a shareable link? Use Hosted Checkout Explore every endpoint with an interactive playground # Amounts & Currencies Source: https://docs.quidkey.com/guides/payment-api/concepts/amounts-and-currencies How Quidkey represents money: integer minor units and ISO 4217 currencies The Payment API represents money in a single, consistent way: amounts are **integer minor units**, and currencies are **ISO 4217** codes. Getting this right is the single most important detail when creating a payment. ## Amounts Are Integer Minor Units On the redirect, embedded, and hosted checkout endpoints, `amount` is an **integer** in the currency's smallest unit: pence for GBP, cents for EUR/USD. For example, `1999` = £19.99. | Display amount | Currency | `amount` to send | | -------------- | -------- | ---------------- | | £19.99 | GBP | `1999` | | £0.20 | GBP | `20` | | €100.00 | EUR | `10000` | | \$5.00 | USD | `500` | ```json theme={null} { "amount": 1999, "currency": "GBP", "locale": "en-GB" } ``` **The 100× footgun.** Sending `20` does **not** mean £20; it means **£0.20**. To charge £20.00, send `2000`. A decimal value such as `19.99` is rejected with a `400` validation error. Always multiply major units by 100 (for two-decimal currencies) before sending. To convert a display price to minor units, multiply by 100 and round to an integer: ```javascript Node.js theme={null} // £19.99 -> 1999 const amount = Math.round(19.99 * 100); // 1999 ``` ```python Python theme={null} # £19.99 -> 1999 amount = round(19.99 * 100) # 1999 ``` ```bash cURL theme={null} # £19.99 is sent as the integer 1999 curl -X POST 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "amount": 1999, "currency": "GBP", "locale": "en-GB" }' ``` Webhook payloads carry the same minor-unit **value**, but as a **JSON string**: `data.object.amount` (and the numeric `fees` fields) arrive as a stringified integer such as `"1999"`, not the number `1999`. Parse with `Number(...)` before doing arithmetic, and divide by 100 only at the presentation layer. See [Webhooks](/guides/payment-api/concepts/webhooks). ## Currencies Are ISO 4217 The `currency` field is an ISO 4217 currency code, for example `GBP`, `EUR`, or `USD`. The currency you send determines how `amount` is interpreted. There is **no FX (currency conversion)** on the redirect payment path. The payment is created and settled in the currency you specify. ## Legacy Decimal Endpoint The legacy `POST /api/v1/payment-requests` (v1) endpoint takes a **decimal** amount in **major** units (`"19.99"` for £19.99), not minor units. It is retained for backward compatibility only. **New integrations should use the minor-unit endpoints** ([redirect](/guides/payment-api/accept-a-payment/redirect), [embedded](/guides/payment-api/accept-a-payment/embedded), and [hosted checkout](/guides/payment-api/accept-a-payment/hosted-checkout)). | Endpoint | Amount format | Example for £19.99 | | ------------------------------------------- | ------------------- | ------------------ | | `POST /api/v1/payment-requests:redirect` | Integer minor units | `1999` | | Embedded / hosted checkout | Integer minor units | `1999` | | `POST /api/v1/payment-requests` (legacy v1) | Decimal major units | `"19.99"` | ## Next Steps Create a payment with minor-unit amounts Validation errors from bad amounts Amounts in webhook payloads Try payments in the sandbox # Authentication Source: https://docs.quidkey.com/guides/payment-api/concepts/authentication Obtain, use, and refresh OAuth 2.0 access tokens for the Payment API Every Payment API request is authenticated with an **OAuth 2.0 Client Credentials** access token. You exchange your `client_id` and `client_secret` for a short-lived `access_token`, then send that token as a Bearer credential on each request. Authentication is **server-side only**. Your `client_secret` and the tokens derived from it must never be exposed in a browser, mobile app, or any client your customers control. Treat them like a password. ## Obtain an Access Token Call `POST /api/v1/oauth2/token` with the `client_credentials` grant. You receive both an `access_token` (for API calls) and a `refresh_token` (to renew it without re-sending your secret). ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/token' \ -H 'Content-Type: application/json' \ -d '{ "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: process.env.QUIDKEY_CLIENT_ID, client_secret: process.env.QUIDKEY_CLIENT_SECRET, }), }); const { data } = await response.json(); const accessToken = data.access_token; ``` ```python Python theme={null} import os import requests response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/token', json={ 'grant_type': 'client_credentials', 'client_id': os.environ['QUIDKEY_CLIENT_ID'], 'client_secret': os.environ['QUIDKEY_CLIENT_SECRET'], }, ) data = response.json()['data'] access_token = data['access_token'] ``` ### Response ```json theme={null} { "success": true, "data": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 900 } } ``` | Field | Description | | --------------- | ------------------------------------------------------------------------------------------------ | | `access_token` | The token you send on every API request. Valid for \~15 minutes. | | `refresh_token` | Used to obtain a new `access_token` without re-sending your `client_secret`. Valid for 24 hours. | | `token_type` | Always `Bearer`. | | `expires_in` | Lifetime of the `access_token` in seconds (`900` = 15 minutes). | ## Authenticate Requests Send the access token in the `Authorization` header on every Payment API call: ```http theme={null} Authorization: Bearer ``` ```bash theme={null} curl 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ ... }' ``` A missing, malformed, or expired token returns `401` (with code `NO_TOKEN` or `INVALID_TOKEN`). Branch on the `401` status rather than the code. See [Errors](/guides/payment-api/concepts/errors) for the full error envelope. ## Refresh Before Expiry Access tokens are intentionally short-lived. Rather than calling `/oauth2/token` for every request, **cache the token and refresh it before it expires** using the `refresh_token`. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/refresh' \ -H 'Content-Type: application/json' \ -d '{ "refresh_token": "YOUR_REFRESH_TOKEN" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); const { data } = await response.json(); const accessToken = data.access_token; ``` ```python Python theme={null} response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/refresh', json={'refresh_token': refresh_token}, ) data = response.json()['data'] access_token = data['access_token'] ``` The response returns a fresh `access_token` and `expires_in`. It does **not** return a new `refresh_token`: reuse your existing one until it expires. Refresh a little **early** (for example when the token is within \~60 seconds of expiry) so an in-flight request never fails on a token that expires mid-call. ## Token Lifecycle ```mermaid theme={null} sequenceDiagram participant App as Your Backend participant API as Quidkey API App->>API: POST /oauth2/token (client_id, client_secret) API-->>App: access_token (15 min) + refresh_token (24 h) App->>API: API request (Authorization: Bearer access_token) API-->>App: 200 OK Note over App: ~15 min later, before expiry App->>API: POST /oauth2/refresh (refresh_token) API-->>App: new access_token ``` | Token | Validity | Renewed by | | --------------- | ------------ | ---------------------------------------------------------------- | | `access_token` | \~15 minutes | `POST /api/v1/oauth2/refresh` | | `refresh_token` | 24 hours | `POST /api/v1/oauth2/token` (with `client_id` + `client_secret`) | When the `refresh_token` itself expires (after 24 hours), authenticate again from scratch using your `client_id` and `client_secret`. ## Best Practices Store `client_id` and `client_secret` in environment variables or a secrets manager (Google Secret Manager, HashiCorp Vault, etc.). Never ship them to a browser, mobile binary, or public repository. Hold the `access_token` in memory across requests for its full lifetime instead of minting a new one each time. Track `expires_in` and refresh proactively. If a request returns `401`, refresh the token once and retry. If the refresh also fails, fall back to a full `client_credentials` exchange. ## Next Steps Try the OAuth flow in the interactive playground Make create calls safe to retry Error envelope, status codes, and handling Base URLs, response format, and conventions # Errors Source: https://docs.quidkey.com/guides/payment-api/concepts/errors Understand the error envelope, HTTP status codes, and how to handle failures When a Payment API request fails, Quidkey returns a consistent JSON envelope and a meaningful HTTP status code. Use the status code and `code` for programmatic handling, and `message` for logging. ## Error Envelope Every error response has `success: false` and an `error` object: ```json theme={null} { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Human-readable error message", "metadata": { "errors": [ { "field": "amount", "message": "Amount must be greater than 0" } ] } } } ``` | Field | Description | | ----------------------- | ----------------------------------------------------------------------------- | | `error.code` | Stable, machine-readable error code. Branch on this in your code. | | `error.message` | Human-readable description. Use for logs and debugging, not for control flow. | | `error.metadata.errors` | Present on validation failures (`400`). An array of per-field problems. | Always branch on `error.code`, never on `error.message`. Messages may be reworded over time; codes are stable. ## HTTP Status Codes | Status | Meaning | Notes | | ------------- | -------------------------------- | ------------------------------------------------------------------------------------ | | `200` / `201` | Success | Request processed; resource created on `201`. | | `400` | `VALIDATION_ERROR` | Malformed or invalid input. Field-level details in `error.metadata.errors`. | | `401` | `NO_TOKEN` / `INVALID_TOKEN` | Missing, malformed, or expired access token. Branch on the **status**, not the code. | | `403` | Forbidden | Authenticated, but insufficient permission for this action. | | `404` | Not found | Resource does not exist **or** belongs to another tenant. | | `409` | `IDEMPOTENT_REQUEST_IN_PROGRESS` | A request with the same idempotency key is still in flight. | | `410` | Gone | Resource expired or revoked (e.g. `PAYMENT_LINK_EXPIRED`). | | `422` | Unprocessable | Request is well-formed but cannot be fulfilled. | | `500` | Internal error | Server-side issue (rare). Safe to retry idempotent requests. | | `503` | `IDEMPOTENCY_STORE_UNAVAILABLE` | Idempotency store temporarily down. Retryable. | ## Validation Errors (400) A `400` indicates the request body or parameters failed validation. The `error.metadata.errors` array pinpoints each offending field, so you can surface precise feedback. ```json theme={null} { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "metadata": { "errors": [ { "field": "amount", "message": "Amount must be a positive integer in minor units" }, { "field": "currency", "message": "currency must be a valid ISO 4217 code" } ] } } } ``` Amounts are **integer minor units**: `1999` means £19.99, not £1,999. Sending a decimal or a major-unit value is a common source of `400` validation errors. See [Amounts & Currencies](/guides/payment-api/concepts/amounts-and-currencies). ## Cross-Tenant Access Returns 404 If you request a resource that exists but belongs to **another merchant**, Quidkey returns `404`, not `403`. This is deliberate. A `403` would confirm the resource exists, leaking information across tenants. Quidkey returns `404` so a resource you cannot access is **indistinguishable** from one that does not exist. Do not treat a `404` as proof a payment was never created. ## Authentication & Permission Errors | Code / Status | Meaning | Resolution | | ------------------------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------- | | `401` (`NO_TOKEN` / `INVALID_TOKEN`) | Missing or expired token | Refresh your access token and retry. See [Authentication](/guides/payment-api/concepts/authentication). | | `403` Forbidden | Token lacks the required permission | Verify the credentials have access to this operation. | | `404` Not found | Resource missing or cross-tenant | Check the ID; confirm it belongs to your merchant. | ## Error Codes Branch on these stable `error.code` values: | Status | `error.code` | Meaning | | ------ | -------------------------------- | --------------------------------------------------------------------------------------------- | | `400` | `VALIDATION_ERROR` | Request body or parameters failed validation. Field-level details in `error.metadata.errors`. | | `409` | `IDEMPOTENT_REQUEST_IN_PROGRESS` | A request with the same idempotency key is still in flight. Retry with the same key. | | `410` | `PAYMENT_LINK_EXPIRED` | The payment link has passed its expiry. | | `410` | `PAYMENT_LINK_NOT_ACTIVE` | The payment link is not in an active state. | | `422` | `MISSING_CONVERTED_AMOUNT` | A required converted amount was not supplied. | | `503` | `IDEMPOTENCY_STORE_UNAVAILABLE` | Idempotency store temporarily down. Retryable. | ## Handling Errors ```javascript Node.js theme={null} const response = await fetch(url, options); const body = await response.json(); if (!body.success) { const { code, message, metadata } = body.error; // A 401 (code NO_TOKEN or INVALID_TOKEN) means the token is missing or expired. if (response.status === 401) { // Refresh the access token and retry once } else switch (code) { case 'IDEMPOTENT_REQUEST_IN_PROGRESS': // Wait briefly, then retry with the SAME idempotency key break; case 'IDEMPOTENCY_STORE_UNAVAILABLE': // Retry with backoff; the request was not processed break; case 'VALIDATION_ERROR': // Surface field errors to the caller for (const e of metadata?.errors ?? []) console.error(e.field, e.message); break; default: console.error(code, message); } } ``` ```python Python theme={null} response = requests.post(url, **options) body = response.json() if not body['success']: error = body['error'] code = error['code'] # A 401 (code NO_TOKEN or INVALID_TOKEN) means the token is missing or expired. if response.status_code == 401: ... # Refresh the access token and retry once elif code == 'IDEMPOTENT_REQUEST_IN_PROGRESS': ... # Wait briefly, then retry with the SAME idempotency key elif code == 'IDEMPOTENCY_STORE_UNAVAILABLE': ... # Retry with backoff; the request was not processed elif code == 'VALIDATION_ERROR': for e in error.get('metadata', {}).get('errors', []): print(e['field'], e['message']) else: print(code, error['message']) ``` ```bash cURL theme={null} # Inspect the status code and body curl -i 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "amount": 1999, "currency": "GBP", "locale": "en-GB" }' ``` Body abbreviated. See the [Redirect guide](/guides/payment-api/accept-a-payment/redirect) for the full required payload. **Retry guidance:** retry `409`, `503`, and `500` with exponential backoff. Do **not** blindly retry `400`, `401`, `403`, `404`, or `410`; fix the request or credentials first. ## Next Steps Safe retries for create requests Resolve 401 errors with token refresh Avoid the most common validation error Response format and conventions # Idempotency Source: https://docs.quidkey.com/guides/payment-api/concepts/idempotency Safely retry create requests without producing duplicate payments Network failures happen: a request times out, a connection drops, or your service retries before it sees the response. Without protection, a retried create call could produce a duplicate payment. **Idempotency keys** let you retry safely: Quidkey recognises the repeat and replays the original result instead of creating a duplicate. ## Send an Idempotency Key Add an `Idempotency-Key` header to create requests. Use a unique value (a UUID v4 is ideal) for each **logical payment attempt**. ```http theme={null} Idempotency-Key: 3f9a2c10-7b6e-4a1c-9d2f-8e5b1c4a6f3d ``` This matters most on create endpoints such as [`POST /api/v1/payment-requests:redirect`](/guides/payment-api/accept-a-payment/redirect), where a duplicate would create a second payment request. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 3f9a2c10-7b6e-4a1c-9d2f-8e5b1c4a6f3d' \ -d '{ "amount": 1999, "currency": "GBP", "locale": "en-GB" }' ``` ```javascript Node.js theme={null} import { randomUUID } from 'crypto'; const idempotencyKey = randomUUID(); const response = await fetch( 'https://core.quidkey.com/api/v1/payment-requests:redirect', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ amount: 1999, currency: 'GBP', locale: 'en-GB' }), }, ); ``` ```python Python theme={null} import uuid idempotency_key = str(uuid.uuid4()) response = requests.post( 'https://core.quidkey.com/api/v1/payment-requests:redirect', headers={ 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', 'Idempotency-Key': idempotency_key, }, json={'amount': 1999, 'currency': 'GBP', 'locale': 'en-GB'}, ) ``` Body abbreviated. See the [Redirect guide](/guides/payment-api/accept-a-payment/redirect) for the full required payload. ## How Replay Works Quidkey scopes idempotency keys per merchant. When it sees a key it has handled before, it returns the original response rather than performing the action again. ```mermaid theme={null} sequenceDiagram participant App as Your Backend participant API as Quidkey API App->>API: POST /payment-requests:redirect (Idempotency-Key: K) API-->>App: 201 Created (payment request P) Note over App: Response lost, retry App->>API: POST /payment-requests:redirect (Idempotency-Key: K) API-->>App: 201 Created (same payment request P) ``` | Scenario | Result | | ------------------------------------------------------- | ----------------------------------------------------------- | | Same key **+** same merchant, request already completed | Replays the **original** response. No duplicate is created. | | Same key, request still in flight (concurrent) | `409 IDEMPOTENT_REQUEST_IN_PROGRESS` | | Idempotency store unavailable | `503 IDEMPOTENCY_STORE_UNAVAILABLE` (retryable) | Idempotency keys are scoped to **your merchant**. The same key value used by a different merchant is treated as an independent request. ## Concurrent Requests If two requests carrying the **same** key arrive before the first finishes, the second returns `409 IDEMPOTENT_REQUEST_IN_PROGRESS`. The first request is still being processed, so wait briefly and retry with the **same key** to pick up the replayed result. ```json theme={null} { "success": false, "error": { "code": "IDEMPOTENT_REQUEST_IN_PROGRESS", "message": "A request with this idempotency key is already being processed." } } ``` ## When the Store Is Unavailable If Quidkey cannot reach the idempotency store, it **fails closed** rather than risk a duplicate, returning `503 IDEMPOTENCY_STORE_UNAVAILABLE`. ```json theme={null} { "success": false, "error": { "code": "IDEMPOTENCY_STORE_UNAVAILABLE", "message": "The idempotency store is temporarily unavailable. Please retry." } } ``` A `503 IDEMPOTENCY_STORE_UNAVAILABLE` means the request was **not** processed. It is safe (and expected) to retry with the **same** idempotency key, ideally with exponential backoff. ## Best Practices Generate a fresh key when you start a **new** payment attempt. Persist it alongside your order so retries of that same attempt reuse it. On a timeout or transient error, retry with the **same** key. A new key on retry defeats the protection and can create a duplicate. If the customer deliberately starts over (e.g. a new checkout for a new basket), mint a new key, since it is a different logical attempt. ## Next Steps Status codes including 409 and 503 Create a redirect payment request Obtain and refresh access tokens Receive payment results reliably # Testing Source: https://docs.quidkey.com/guides/payment-api/concepts/testing Run payments against the sandbox without moving real money Before going live, exercise the full payment flow in a sandbox. Quidkey routes test payments to a sandbox provider so you can integrate, verify webhooks, and rehearse edge cases, **with no real bank movement**. ## Route a Payment to the Sandbox Pass `test_transaction: true` when you create a payment. Quidkey routes it to the sandbox provider instead of a live bank connection. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "amount": 1999, "currency": "GBP", "locale": "en-GB", "test_transaction": true }' ``` ```javascript Node.js theme={null} const response = await fetch( 'https://core.quidkey.com/api/v1/payment-requests:redirect', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 1999, currency: 'GBP', locale: 'en-GB', test_transaction: true, }), }, ); ``` ```python Python theme={null} response = requests.post( 'https://core.quidkey.com/api/v1/payment-requests:redirect', headers={ 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', }, json={ 'amount': 1999, 'currency': 'GBP', 'locale': 'en-GB', 'test_transaction': True, }, ) ``` Body abbreviated. See the [Redirect guide](/guides/payment-api/accept-a-payment/redirect) for the full required payload. Always set `test_transaction: true` in development. A request without it is treated as **live** and may attempt a real bank movement. ## Identifying Test Events Webhooks for sandbox payments carry test markers, so your handler can tell sandbox traffic apart from production. The event payload includes a `test` flag (and `sandbox` indicators) on the payment object: ```json theme={null} { "id": "evt_9f8b2c14-3d6a-4e21-bb02-7c1d9a4e5f60", "object": "event", "type": "quidkey.payment_request.succeeded", "data": { "object": { "id": "4a7b1e2c-9d83-4f10-a6b5-2e9c7d041f8a", "amount": "1999", "currency": "GBP", "status": "completed", "test": true, "metadata": { "order_id": "ORD-123", "payment_token": "ptok_..." } } } } ``` Gate your fulfilment logic on `data.object.test`. In non-production environments, ignore live events; in production, ignore test events. This prevents a stray sandbox webhook from triggering real fulfilment. ## Sandbox vs. Live | Aspect | Sandbox (`test_transaction: true`) | Live | | -------------- | ------------------------------------------------------------------- | ---------------------- | | Bank movement | None: simulated by the sandbox provider | Real funds move | | Webhook events | Same shapes, with `test`/`sandbox` markers | Production events | | Credentials | Use a separate set of credentials / webhook secret if you have them | Production credentials | If your account provides **separate** sandbox credentials and a separate webhook signing secret, use them for test traffic and keep them distinct from production. This keeps environments cleanly isolated. ## A Typical Test Pass Create a payment request with `test_transaction: true` and complete it through the sandbox flow. Verify your endpoint receives the event, the signature validates, and `data.object.test` is `true`. See [Webhooks](/guides/payment-api/concepts/webhooks). Test failure and cancellation paths, and confirm your idempotency and error handling behave as expected. Look up the payment via the status endpoint to confirm your records match Quidkey's. ## Next Steps Verify signatures and handle test events Create your first test payment Send amounts correctly in tests Test your error handling paths # Webhooks Source: https://docs.quidkey.com/guides/payment-api/concepts/webhooks Receive payment results securely: register endpoints, verify signatures, and handle events Webhooks are how your backend learns the outcome of a payment. When a payment changes state, Quidkey delivers a signed event to your registered URL. **This is the source of truth**: never rely on a browser redirect to confirm a payment. Redirects can fail, be interrupted, or be triggered by a customer who never paid. Always confirm payment via the webhook (or the [status endpoint](#reconciling-missed-events)), not the redirect. ## Register Your Endpoint Tell Quidkey where to deliver events. ```bash theme={null} curl -X POST 'https://core.quidkey.com/api/v1/webhooks' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "webhook_url": "https://api.yoursite.com/webhooks/quidkey" }' ``` Generate the secret used to sign every event. ```bash theme={null} curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` The response returns the secret: ```json theme={null} { "success": true, "data": { "webhook_secret": "whsec_4eC39HqLyjWDarjtT1zdp7dc..." } } ``` The `webhook_secret` is returned **once**. Store it immediately in a secure vault (AWS Secrets Manager, HashiCorp Vault, etc.). If you lose it, generate a new one. Roll or revoke the secret during incident response. Generate a new one afterwards. ```bash theme={null} curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret/revoke' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ## Named Endpoints & Per-Payment Routing The single webhook URL above is the default destination for every payment. If you create payments through the [embedded integration](/guides/embedded-flow/overview), you can also register **multiple named endpoints** in the [Console](https://console.quidkey.com) (**Settings → Webhooks**) and route each payment to one or more of them. * Each endpoint has its own **URL**, its own **signing secret**, and an optional **test/live scope** (so a test-only receiver never sees live traffic). * An embedded payment names endpoints in its `webhook_endpoints` field, up to **10**. Its webhook is **fanned out** to every named endpoint, each signed with that endpoint's own secret. This is built for platforms that send on behalf of several downstream businesses, each verifying with its own secret. * When a payment names no endpoint, Quidkey resolves the destination in order: the default `webhook_url` above, then the merchant's **default endpoint**, then the single active endpoint. Named endpoints and per-payment routing apply to **embedded** payments only. Hosted-checkout and redirect payments always deliver to the single default `webhook_url` registered above. See [Named Webhook Endpoints](/guides/embedded-flow/after-payment#named-webhook-endpoints) for the full guide. ## Event Payload Quidkey sends a Stripe-style `Event` envelope, so existing Stripe tooling can be reused. The payment object lives at `data.object`. ```json theme={null} { "id": "evt_9f8b2c14-3d6a-4e21-bb02-7c1d9a4e5f60", "object": "event", "created": 1716148300, "type": "quidkey.payment_request.succeeded", "data": { "object": { "id": "4a7b1e2c-9d83-4f10-a6b5-2e9c7d041f8a", "amount": "1999", "currency": "GBP", "status": "completed", "test": false, "metadata": { "order_id": "ORD-123", "payment_token": "ptok_...", "bank_name": "Monzo" }, "fees": { "total_fees": "30", "fees_currency": "GBP", "fees_breakdown": [ { "id": "c1f0a9e7-5b62-4d38-9a14-8e3b6c2d70f5", "type": "percentage", "amount": "30", "currency": "GBP", "rate_type": "domestic_percent_fee", "rate_value": "1.5", "notes": "1.5% fee on 1999 GBP (minor units)" } ] } } } } ``` On the wire, `amount` is a **string** holding a stringified integer of minor units (`"1999"` = £19.99), and the `fees` numeric fields (`total_fees`, each fee's `amount`, `rate_value`) are strings too. Parse them before doing arithmetic. | Field | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Unique event ID (`evt_...`). **Use this to de-duplicate**: see [below](#delivery-and-de-duplication). | | `object` | Always `event`. | | `created` | Event creation time, Unix epoch seconds. | | `type` | The event type. See the [catalog](#event-catalog). | | `data.object.id` | The payment request ID. | | `data.object.amount` | Amount in minor units, sent as a **stringified integer** (`"1999"` = £19.99). See [Amounts & Currencies](/guides/payment-api/concepts/amounts-and-currencies). | | `data.object.currency` | ISO 4217 currency code. | | `data.object.status` | Underlying payment status. | | `data.object.test` | `true` for sandbox payments. See [Testing](/guides/payment-api/concepts/testing). | | `data.object.metadata` | Includes `order_id`, `payment_token`, and `bank_name`. | | `data.object.fees` | Fee breakdown. Included only on **succeeded** events. | ## Verify Every Event Quidkey signs each delivery with an HMAC. **You must verify the signature before trusting or parsing the payload**, otherwise anyone who discovers your URL could forge events. ### Signature Headers Each request includes these headers: | Header | Description | | ------------------ | --------------------------------------------- | | `Stripe-Signature` | `t=,v1=` | | `X-Signature` | Same value as `Stripe-Signature` (use either) | | `X-Timestamp` | Unix epoch seconds (the `t` value) | | `X-Client-Id` | Your `client_id` | `Stripe-Signature` and `X-Signature` carry the **identical** value. Use `Stripe-Signature` with the Stripe SDK, or either header with a custom verifier. ### How the Signature Is Computed The signature is an **HMAC SHA-256** over the string `` `${timestamp}.${rawBody}` `` (the timestamp, a literal dot, then the raw request body), keyed by your webhook signing secret. The HMAC key is the **full** secret, including the `whsec_` prefix, used **verbatim**. Do **not** strip `whsec_` before computing the HMAC. Verify against the **raw request body bytes**, exactly as received. If your framework parses JSON before you can read the raw body, the bytes change and verification will fail. Capture the raw body first (e.g. `express.raw()`), then parse only after the signature checks out. ### Verification Code Quidkey's envelope and signature scheme are Stripe-compatible, so the Stripe SDK verifies them directly. Pass the **raw** body and the `Stripe-Signature` header, keyed by your full `whsec_...` secret. ```javascript theme={null} import express from 'express'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_API_KEY); const app = express(); // IMPORTANT: raw body so the bytes match what was signed app.post( '/webhooks/quidkey', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, // raw Buffer req.get('stripe-signature'), // t=...,v1=... process.env.QUIDKEY_WEBHOOK_SECRET, // full "whsec_..." secret ); } catch (err) { return res.status(400).send(`Invalid signature: ${err.message}`); } // event is verified: safe to handle handleEvent(event); res.status(200).send('OK'); }, ); ``` If you do not use the Stripe SDK, verify the HMAC yourself with a **timing-safe** comparison and a timestamp tolerance to reject replays. ```javascript theme={null} import crypto from 'crypto'; import express from 'express'; const TOLERANCE_SECONDS = 300; // reject events older than 5 minutes function verifyQuidkeySignature(rawBody, signatureHeader, secret) { const match = signatureHeader?.match(/^t=(\d+),v1=([a-f0-9]+)$/); if (!match) throw new Error('Malformed signature header'); const [, timestamp, providedV1] = match; // Reject stale events (replay protection) const age = Math.floor(Date.now() / 1000) - Number(timestamp); if (Math.abs(age) > TOLERANCE_SECONDS) throw new Error('Timestamp outside tolerance'); // HMAC over `${timestamp}.${rawBody}`, keyed by the FULL secret (incl. whsec_) const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(providedV1); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { throw new Error('Signature mismatch'); } } const app = express(); app.post( '/webhooks/quidkey', express.raw({ type: 'application/json' }), (req, res) => { try { verifyQuidkeySignature( req.body, // raw Buffer req.get('x-signature'), // or req.get('stripe-signature') process.env.QUIDKEY_WEBHOOK_SECRET, // full "whsec_..." secret ); } catch (err) { return res.status(400).send(`Invalid signature: ${err.message}`); } const event = JSON.parse(req.body.toString()); // parse only after verifying handleEvent(event); res.status(200).send('OK'); }, ); ``` ```python theme={null} import hashlib import hmac import time TOLERANCE_SECONDS = 300 # reject events older than 5 minutes def verify_quidkey_signature(raw_body: bytes, signature_header: str, secret: str) -> None: # Header format: t=,v1= parts = dict(p.split('=', 1) for p in signature_header.split(',')) timestamp, provided_v1 = parts['t'], parts['v1'] # Reject stale events (replay protection) if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: raise ValueError('Timestamp outside tolerance') # HMAC over f"{timestamp}.{raw_body}", keyed by the FULL secret (incl. whsec_) signed_payload = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, provided_v1): raise ValueError('Signature mismatch') ``` ## Event Catalog | Event type | When it fires | Notes | | ----------------------------------- | --------------------------------------------------- | -------------------------------------------------- | | `quidkey.payment_request.succeeded` | The transaction reached `completed` (or `received`) | Includes the `fees` object. Fulfil the order here. | | `quidkey.payment_request.failed` | The payment attempt failed | No fees. | | `quidkey.payment_request.canceled` | The payment was cancelled | **One `l`**. See the warning below. | | `quidkey.payment_request.pending` | Payment is in progress | The default in-flight state. | | `quidkey.payment_request.reversed` | A refund or reversal occurred | Adjust your records accordingly. | The cancellation event is spelled `quidkey.payment_request.canceled`: **one `l`**, US spelling. Matching `cancelled` (two `l`s) will silently miss the event. The `failed` and `reversed` events carry the same envelope, without a `fees` object: ```json failed theme={null} { "id": "evt_2b6d4e90-8c31-4a57-bf09-1d2e3f4a5b6c", "object": "event", "type": "quidkey.payment_request.failed", "data": { "object": { "id": "4a7b1e2c-9d83-4f10-a6b5-2e9c7d041f8a", "amount": "1999", "currency": "GBP", "status": "failed", "test": false, "metadata": { "order_id": "ORD-123" } } } } ``` ```json reversed theme={null} { "id": "evt_7e1a9c52-4f80-4b63-a2d1-6c9b8e0f3a47", "object": "event", "type": "quidkey.payment_request.reversed", "data": { "object": { "id": "4a7b1e2c-9d83-4f10-a6b5-2e9c7d041f8a", "amount": "1999", "currency": "GBP", "status": "completed", "test": false, "metadata": { "order_id": "ORD-123" } } } } ``` **Terminal vs transitional states.** `pending` is transitional and resolves to one of `succeeded`, `failed`, or `canceled`. `succeeded` is otherwise terminal, but a settled payment can still move to `reversed` later if it is refunded or reversed. A reversal is identified by the event `type` (`quidkey.payment_request.reversed`); there is no distinct `reversed` status value, so `data.object.status` stays the underlying transaction status (e.g. `completed`). ### Handling Events ```javascript Node.js theme={null} function handleEvent(event) { // De-duplicate: skip if this event.id was already processed if (alreadyProcessed(event.id)) return; const payment = event.data.object; switch (event.type) { case 'quidkey.payment_request.succeeded': fulfilOrder(payment.metadata.order_id, payment); if (payment.fees) recordFees(payment.metadata.order_id, payment.fees); break; case 'quidkey.payment_request.failed': markFailed(payment.metadata.order_id); break; case 'quidkey.payment_request.canceled': // one 'l' markCancelled(payment.metadata.order_id); break; case 'quidkey.payment_request.pending': markPending(payment.metadata.order_id); break; case 'quidkey.payment_request.reversed': reverseOrder(payment.metadata.order_id); break; } markProcessed(event.id); } ``` ```python Python theme={null} def handle_event(event): # De-duplicate: skip if this event['id'] was already processed if already_processed(event['id']): return payment = event['data']['object'] order_id = payment['metadata']['order_id'] event_type = event['type'] if event_type == 'quidkey.payment_request.succeeded': fulfil_order(order_id, payment) if payment.get('fees'): record_fees(order_id, payment['fees']) elif event_type == 'quidkey.payment_request.failed': mark_failed(order_id) elif event_type == 'quidkey.payment_request.canceled': # one 'l' mark_cancelled(order_id) elif event_type == 'quidkey.payment_request.pending': mark_pending(order_id) elif event_type == 'quidkey.payment_request.reversed': reverse_order(order_id) mark_processed(event['id']) ``` ## Delivery and De-duplication Quidkey makes a **single** delivery attempt per event. There is **no automatic retry**. If your endpoint is down or returns a non-`2xx`, the event is not retried automatically; you resend it manually. **Manual resend.** You can resend a delivery from the Quidkey Console. A resend **reuses the same `event.id`** and is re-signed with a **fresh** timestamp (so the signature and timestamp tolerance still validate). Because resends reuse the same `event.id`, your handler **must de-duplicate on `event.id`**. Record processed event IDs and skip any you have already handled; otherwise a resend will fulfil the same order twice. | Property | Behaviour | | ----------------- | --------------------------------------------- | | Delivery attempts | Single attempt; **no** automatic retry | | Resend | Manual, from the Console | | Resend `event.id` | Same as the original → **de-duplicate on it** | | Resend signature | Re-signed with a fresh timestamp | ## Reconciling Missed Events If you suspect a webhook was missed (endpoint downtime, etc.), reconcile by querying the payment's current status directly. This protected merchant endpoint returns the authoritative state: ```bash theme={null} curl 'https://core.quidkey.com/api/v1/payment-requests/PAYMENT_REQUEST_ID/status' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` Use the status endpoint as a backstop, not a substitute for webhooks. Poll it for payments where you expected an event but never received one, then resend from the Console if needed. ## Best Practices Capture the raw body, verify the signature against it (full secret, including `whsec_`), and only then parse the JSON. Reject anything that fails with a `400`. Persist every processed `event.id`. Resends reuse the ID, so this is what keeps fulfilment exactly-once. Return `200` quickly and do heavy work (fulfilment, emails) asynchronously. A slow handler can time out the delivery. Reject events whose `X-Timestamp` is outside \~300 seconds of now to defend against replay attacks. ## Next Steps Trigger and verify test webhooks Status codes and the error envelope How amounts appear in payloads Build the payment flow that triggers these events # Payment API Source: https://docs.quidkey.com/guides/payment-api/overview Accept bank-to-bank payments with a single API. Redirect, Embedded, or Hosted Checkout Accept bank-to-bank payments with Quidkey: create a payment from your backend, send your buyer to a bank to approve it, and receive a webhook when it settles. Every integration path uses the same credentials, authentication, and webhooks. You choose how the payment is presented to the buyer. Want zero frontend work? Use a **Redirect** and send the buyer to a Quidkey-hosted bank page. Already running a Stripe checkout? Drop Quidkey in alongside it with the **Embedded** flow. Sending an invoice? Generate a **Hosted Checkout** link and share the URL. **Amounts are integer minor units** on every Payment API endpoint (Redirect, Embedded, and Hosted Checkout): `1000` = £10.00, `2550` = €25.50. See [Amounts & Currencies](/guides/payment-api/concepts/amounts-and-currencies). **Before you start:** sign up and grab your `client_id` and `client_secret` from the [Quidkey Console](https://console.quidkey.com). You'll exchange them for an access token on your first call. ## Base URL All Payment API requests go to `https://core.quidkey.com`. There's no separate sandbox host: set `test_transaction: true` to route a request to the sandbox (see [Testing](/guides/payment-api/concepts/testing)). Always set `test_transaction: true` while developing so you never move real money. Test payments flow through the same endpoints and fire the same webhooks as live payments. ## Three Ways to Accept a Payment Every path uses the same OAuth credentials, integer minor-unit amounts, and webhooks. They differ only in where the buyer completes the payment and how much frontend code you write. | | **Redirect (Pay by Bank)** | **Embedded (with Stripe)** | **Hosted Checkout** | | ---------------------- | ---------------------------------------- | ----------------------------------------- | -------------------------------------- | | **Best for** | Fast launch, full control of your own UI | E-commerce checkouts already using Stripe | Invoicing, ad-hoc and no-code payments | | **Frontend effort** | Redirect the buyer to a URL | Embed an iframe + Stripe mutual exclusion | None: share a link | | **Buyer details** | Collected by you, sent at create time | Collected by you, sent at create time | Collected on the Quidkey checkout page | | **Buyer completes on** | Quidkey-hosted bank page | Inline on your site, then their bank | Quidkey-hosted checkout page | | **Amount format** | Integer minor units | Integer minor units | Integer minor units | **Need more than one?** They all share the same backend API and webhook infrastructure, so you can mix them freely: Embedded on your checkout page, Hosted Checkout for invoice emails, and Redirect for a lightweight standalone flow. ## The Create → Redirect/Embed → Webhook Model Every Payment API integration follows the same three-beat rhythm, no matter which path you pick: ```mermaid theme={null} sequenceDiagram autonumber actor Buyer participant Merchant as Merchant (server) participant Quidkey participant Bank as Buyer's Bank Merchant->>Quidkey: 1. Create a payment (amount, currency, buyer) Quidkey-->>Merchant: redirect_url / payment_token / payment_link_url Merchant-->>Buyer: 2. Redirect or embed the payment Buyer->>Bank: Authenticate & approve Bank-->>Quidkey: Payment result Quidkey-->>Merchant: 3. Webhook (succeeded / failed / ...) ``` Call the create endpoint for your chosen path with an authenticated request. You get back a `redirect_url`, a `payment_token`, or a `payment_link_url`. Send the buyer to the bank page (Redirect / Hosted Checkout) or render the inline iframe (Embedded). The buyer approves the payment in their own bank. Quidkey sends a [webhook](/guides/payment-api/concepts/webhooks) to your backend with the final result. **The webhook is the source of truth**, not the browser redirect. Fulfil orders only when you receive `quidkey.payment_request.succeeded`. Webhook delivery is a [single attempt with no automatic retry](/guides/payment-api/concepts/webhooks), so the merchant status endpoint is your reconcile backstop for any event you never received. ## Start Here Your first payment in about 10 minutes, end to end Exchange your client credentials for an access token ### Accept a Payment Create a payment and redirect the buyer to a Quidkey-hosted bank page Add Quidkey alongside your existing Stripe Payment Element Generate a shareable checkout URL in one API call ### Core Concepts Obtain, use, and refresh OAuth 2.0 access tokens Send an `Idempotency-Key` so retries never create duplicate payments Receive payment status updates and verify signatures The error envelope, status codes, and how to handle failures Integer minor units and ISO 4217 currencies across the Payment API Explore every endpoint with an interactive playground # Quickstart Source: https://docs.quidkey.com/guides/payment-api/quickstart Your first Quidkey payment in about 10 minutes, end to end This guide takes you from zero to a completed bank payment in about ten minutes. You'll authenticate, create a **Redirect** payment in test mode, send a buyer to their bank, and confirm the result with a webhook. Every request is copy-pasteable. Create your merchant account to get your `client_id` and `client_secret` **Test mode.** Every request below sets `test_transaction: true`, so no real money moves. Test payments use the same endpoints and fire the same webhooks as live ones, so the integration you build here is the integration you ship. See [Testing](/guides/payment-api/concepts/testing) for more. Exchange your credentials for an access token using the OAuth 2.0 client credentials flow. The token is valid for 15 minutes. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/token' \ -H 'Content-Type: application/json' \ -d '{ "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: process.env.QUIDKEY_CLIENT_ID, client_secret: process.env.QUIDKEY_CLIENT_SECRET, }), }); const { data } = await response.json(); const accessToken = data.access_token; ``` ```python Python theme={null} import os import requests response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/token', json={ 'grant_type': 'client_credentials', 'client_id': os.environ['QUIDKEY_CLIENT_ID'], 'client_secret': os.environ['QUIDKEY_CLIENT_SECRET'], }, ) data = response.json()['data'] access_token = data['access_token'] ``` You should receive an `access_token` valid for 15 minutes, plus a `refresh_token` and `expires_in`. Send the access token as `Authorization: Bearer ` on every request that follows. No credentials yet? [Try authentication in the API playground](/api-reference/endpoint/issue-token) first. No setup required. For the full token lifecycle, see [Authentication](/guides/payment-api/concepts/authentication). Create a payment with `POST /api/v1/payment-requests:redirect`. Amounts are integer minor units, so `2550` means £25.50. Send an `Idempotency-Key` so a retried request never creates a second payment, and keep `test_transaction: true` while developing. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-requests:redirect' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Idempotency-Key: order-1001-attempt-1' \ -H 'Content-Type: application/json' \ -d '{ "merchant_id": "your-merchant-id", "customer": { "name": "Jane Buyer", "email": "jane@example.com", "phone_number": "+447700900123" }, "billing_address": { "address_line1": "1 Market Street", "city": "London", "postal_code": "EC1A 1AA", "country": "GB" }, "amount": 2550, "currency": "GBP", "payment_reference": "ORDER1001", "locale": "en-GB", "success_url_redirect": "https://yoursite.com/success", "fail_url_redirect": "https://yoursite.com/failure", "test_transaction": true }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/payment-requests:redirect', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Idempotency-Key': 'order-1001-attempt-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ merchant_id: process.env.QUIDKEY_MERCHANT_ID, customer: { name: 'Jane Buyer', email: 'jane@example.com', phone_number: '+447700900123' }, billing_address: { address_line1: '1 Market Street', city: 'London', postal_code: 'EC1A 1AA', country: 'GB' }, amount: 2550, // £25.50 in minor units currency: 'GBP', payment_reference: 'ORDER1001', locale: 'en-GB', success_url_redirect: 'https://yoursite.com/success', fail_url_redirect: 'https://yoursite.com/failure', test_transaction: true }) }); const { data } = await response.json(); console.log('Redirect the buyer to:', data.redirect_url); ``` ```python Python theme={null} import os, requests response = requests.post( 'https://core.quidkey.com/api/v1/payment-requests:redirect', headers={ 'Authorization': f'Bearer {access_token}', 'Idempotency-Key': 'order-1001-attempt-1' }, json={ 'merchant_id': os.getenv('QUIDKEY_MERCHANT_ID'), 'customer': { 'name': 'Jane Buyer', 'email': 'jane@example.com', 'phone_number': '+447700900123' }, 'billing_address': { 'address_line1': '1 Market Street', 'city': 'London', 'postal_code': 'EC1A 1AA', 'country': 'GB' }, 'amount': 2550, # £25.50 in minor units 'currency': 'GBP', 'payment_reference': 'ORDER1001', 'locale': 'en-GB', 'success_url_redirect': 'https://yoursite.com/success', 'fail_url_redirect': 'https://yoursite.com/failure', 'test_transaction': True } ) data = response.json()['data'] print('Redirect the buyer to:', data['redirect_url']) ``` A successful call returns **201 Created** with `{ "success": true, "data": { "redirect_url": "..." } }`. Save the `redirect_url`. Send the buyer's browser to the `redirect_url`. It opens a Quidkey-hosted bank page where they pick their bank and approve the payment. When they finish, Quidkey sends them to your `success_url_redirect` or `fail_url_redirect`. ```javascript Node.js theme={null} // In your route handler, after creating the payment: res.redirect(303, data.redirect_url); ``` The redirect back to your site tells you the buyer **returned**, not that the payment **settled**. Treat the webhook as authoritative before fulfilling the order. Quidkey sends a webhook to your registered endpoint with the final result. Listen for `quidkey.payment_request.succeeded` and only then mark the order as paid. Register your endpoint, verify signatures, and handle every payment status event ## What's Next You just built the Redirect flow. Explore the full feature set, or pick a different integration path. The full guide: request reference, deep-linking to a bank, and webhooks Add Quidkey inline alongside your Stripe Payment Element Generate a shareable checkout link in one API call Explore every endpoint with an interactive playground # After Payment Source: https://docs.quidkey.com/guides/payment-links/after-payment Track payment status, handle webhooks, and manage your payment links Once you've created and shared a payment link, you can track its status, receive webhook notifications when a payment completes, and manage your links through the API. ## Check Link Status Retrieve a payment link by its ID to check the current status. ```bash cURL theme={null} curl 'https://core.quidkey.com/api/v1/payment-links/LINK_ID' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ```javascript Node.js theme={null} const response = await fetch( `https://core.quidkey.com/api/v1/payment-links/${linkId}`, { headers: { 'Authorization': `Bearer ${accessToken}` } } ); const { data } = await response.json(); console.log('Status:', data.status); console.log('Views:', data.views_count); console.log('Transaction:', data.transaction_id); ``` ```python Python theme={null} response = requests.get( f'https://core.quidkey.com/api/v1/payment-links/{link_id}', headers={'Authorization': f'Bearer {access_token}'} ) data = response.json()['data'] print(f"Status: {data['status']}") print(f"Views: {data['views_count']}") print(f"Transaction: {data['transaction_id']}") ``` ### Response ```json theme={null} { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "merchant": { "id": "m1234567-abcd-ef01-2345-678901234567", "brand_name": "Acme Corp" }, "amount": "50.00", "currency": "EUR", "payment_reference": "INV-2024-001", "order_id": null, "status": "used", "link_type": "single_use", "views_count": 3, "first_viewed_at": "2024-04-01T14:30:00.000Z", "expires_at": "2024-04-07T12:00:00.000Z", "created_at": "2024-03-31T12:00:00.000Z", "transaction_id": "t9876543-dcba-fe01-2345-678901234567", "locale": "en", "metadata": null, "payment_link_url": "https://core.quidkey.com/payment-link/a1b2c3d4e5f6...", "redirect_urls": null } } ``` ### Response Fields | Field | Description | | ------------------ | ---------------------------------------------------------------------- | | `status` | Current link status: `active`, `used`, `expired`, or `cancelled` | | `views_count` | Number of times the checkout page has been opened | | `first_viewed_at` | When the link was first opened (null if never viewed) | | `transaction_id` | The resulting transaction ID when payment is complete (null otherwise) | | `payment_link_url` | The shareable URL (recovered from encrypted storage) | **Link-to-transaction navigation:** When `transaction_id` is present, you can use it to look up the full transaction details in the Quidkey Console or via the API. ## Status Transitions Payment link status changes are driven by customer actions and system events: ```mermaid theme={null} stateDiagram-v2 [*] --> ACTIVE: Link created ACTIVE --> USED: Payment completed (bank callback) ACTIVE --> EXPIRED: Past expiry time ACTIVE --> CANCELLED: Cancelled via API ``` | Transition | Trigger | | ------------------ | ----------------------------------------------------------------------------------- | | ACTIVE → USED | The customer's bank confirms the payment (callback). Single-use links only. | | ACTIVE → EXPIRED | The link passes its `expires_at` time. Checked on demand when the link is accessed. | | ACTIVE → CANCELLED | You cancel the link via the API. | **Expiry is checked on demand.** Quidkey marks a link as expired when it is next accessed (via the public checkout page or the API), not via a background job. This means a link's status in the database may show `active` until someone accesses it after the expiry time. ## Redirect URLs If you provided `redirect_urls` when creating the payment link, the customer is redirected to your URLs instead of Quidkey's default pages. Quidkey appends query parameters to help you correlate the redirect: | Parameter | Description | | ------------------- | --------------------------------------- | | `status` | `success` or `failed` | | `payment_reference` | The payment reference from the link | | `order_id` | Your order ID (if provided at creation) | Example redirect after successful payment: ``` https://yoursite.com/payment/success?status=success&payment_reference=INV-2024-001&order_id=ORD-123 ``` Custom redirects are informational only. Do not use them to confirm payment status. Always use webhooks or the API to verify that payment was actually completed. A customer could manually navigate to your success URL without paying. ## Webhook Notifications When a payment is completed through a payment link, you receive the same webhook notification as payments made through the Embedded Flow. The webhook payload includes the transaction details. To set up webhooks, see the [Webhook documentation](/api-reference/webhook/register-or-update-a-merchant-webhook-url). ## List Payment Links Retrieve all payment links with optional filtering and pagination. ```bash cURL theme={null} # List all active links curl 'https://core.quidkey.com/api/v1/payment-links?status=active&limit=20' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' # Search by payment reference curl 'https://core.quidkey.com/api/v1/payment-links?search=INV-2024' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ```javascript Node.js theme={null} // List all active links const response = await fetch( 'https://core.quidkey.com/api/v1/payment-links?status=active&limit=20', { headers: { 'Authorization': `Bearer ${accessToken}` } } ); const { data, pagination } = await response.json(); console.log(`Found ${pagination.total} links (page ${pagination.page})`); ``` ```python Python theme={null} # List all active links response = requests.get( 'https://core.quidkey.com/api/v1/payment-links', headers={'Authorization': f'Bearer {access_token}'}, params={'status': 'active', 'limit': 20} ) result = response.json() print(f"Found {result['pagination']['total']} links") ``` ### Query Parameters | Parameter | Type | Default | Description | | ------------- | ------- | ------- | ---------------------------------------------------------- | | `status` | string | *none* | Filter by status: `active`, `used`, `expired`, `cancelled` | | `search` | string | *none* | Search by payment reference or order ID | | `merchant_id` | string | *none* | Filter by merchant ID (partner authentication only) | | `page` | integer | `1` | Page number (1-based) | | `limit` | integer | `20` | Items per page (max 100) | ### Paginated Response ```json theme={null} { "success": true, "data": [ { "id": "a1b2c3d4-...", "merchant": { "id": "m1234...", "brand_name": "Acme Corp" }, "amount": "50.00", "currency": "EUR", "payment_reference": "INV-2024-001", "order_id": null, "status": "active", "link_type": "single_use", "views_count": 0, "first_viewed_at": null, "expires_at": "2024-04-07T12:00:00.000Z", "created_at": "2024-03-31T12:00:00.000Z", "transaction_id": null } ], "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 } } ``` ## Error Handling Payment link endpoints return consistent error responses: | Status | Error Code | Meaning | | ------ | ------------------------- | ----------------------------------- | | `404` | `PAYMENT_LINK_NOT_FOUND` | Token or ID does not match any link | | `410` | `PAYMENT_LINK_NOT_ACTIVE` | Link is used, expired, or cancelled | | `400` | `INVALID_INPUT` | Request validation failed | | `401` | `UNAUTHORIZED` | Missing or invalid authentication | **Expired links return details, not errors.** The public `GET /payment-links/:token/details` endpoint returns the link with `status: "expired"` rather than throwing an error. This allows the checkout page to show an appropriate message to the customer. ## Next Steps Payment Links overview and lifecycle Generate and share your first checkout link Configure webhooks to receive payment notifications Full endpoint documentation with interactive playground # Checkout Experience Source: https://docs.quidkey.com/guides/payment-links/checkout-experience What your customers see when they open a payment link When a customer clicks a payment link, they're taken to a Quidkey-hosted checkout page. This page collects their details, lets them select their bank, and redirects them to complete the payment, all without any frontend code on your side. ## Customer Journey ```mermaid theme={null} flowchart LR A[Click link] --> B[Enter details] B --> C[Select bank] C --> D[Authorize at bank] D --> E[Payment complete] ``` The customer clicks the payment link URL you shared. Quidkey loads the hosted checkout page showing the merchant name, payment amount, and reference. The checkout page presents a form where the customer enters: * **Full name** * **Email address** * **Phone number** (E.164 format) * **Country** These details are used to create the payment request and help Quidkey predict the customer's bank. The order summary shows the merchant, reference, and total alongside the predicted bank. Branded checkout page showing the customer details form and order summary After submitting the form, Quidkey's bank selection iframe appears. Quidkey automatically predicts and pre-selects the customer's bank based on their country and information. The customer can change the selection if needed. The customer is redirected to their bank's authentication page (or mobile app) to approve the payment. This is the standard Open Banking authorization flow: the bank verifies the customer's identity. Customer approving the payment in their banking app After authorization, the customer is redirected to a success or failure page. If the merchant provided custom `redirect_urls` when creating the link, the customer is sent to the merchant's site instead of Quidkey's default pages. You receive a webhook notification with the payment result regardless of redirect configuration. Branded payment successful confirmation page shown to the customer ## Checkout Page Layout The checkout page uses a responsive two-column layout: | Section | Content | | ---------------- | ---------------------------------------------------------------------------------------------- | | **Left column** | Checkout header, merchant name, customer form, bank selection (mobile), trust badges | | **Right column** | Order summary card: merchant avatar, payment reference, total amount, bank selection (desktop) | On mobile devices, the layout collapses to a single column with the order summary above the form. ## Re-confirmation Support If a customer has already submitted the form but hasn't completed the payment (e.g., they closed the bank app), they can return to the same link and update their details. The checkout page allows re-confirmation. Submitting the form again creates a new payment request with the updated customer information. **Single-use links** remain active until the payment is actually completed (confirmed by the bank callback). This means a customer can retry or update their details as many times as needed before paying. ## Status-Based Display The checkout page adapts based on the payment link's current status: | Status | What the customer sees | | ------------- | --------------------------------------------------------- | | **ACTIVE** | Full checkout form with payment flow | | **USED** | Message indicating the payment has already been completed | | **EXPIRED** | Message indicating the link has expired | | **CANCELLED** | Message indicating the link is no longer available | Only `ACTIVE` links show the payment form. All other statuses display an informational message. ## Security The hosted checkout page is designed with security in mind: * **Token-based access**: links use 256-bit cryptographic tokens. The token is hashed (SHA-256) before database lookup, so raw tokens are never stored. * **No sensitive data exposure**: the public checkout endpoint only returns the merchant name, amount, currency, and reference. Internal IDs, merchant IDs, and tracking data are never exposed. * **Bank-grade authentication**: customers authorize payments directly in their bank app. Quidkey never sees banking credentials. ## Next Steps Track payment status and manage your links Generate and share your first checkout link # Create from the Console Source: https://docs.quidkey.com/guides/payment-links/console Create and share a payment link from the Quidkey Console - no code required Payment Links let you collect an instant Pay by Bank payment with nothing but a link, no card required and no account creation for your customer. You can create one directly from the Quidkey Console in a few clicks, with no code at all. **Prefer to integrate programmatically?** Developers can create the same links via the API. See [Create a Checkout Link](/guides/payment-links/create). ## Step 1: Create the payment link Log in to the [Quidkey Console](https://console.quidkey.com), go to **Payment Links**, and click **Create Payment Link**. Fill in the link details: * **Amount** — the amount you want to collect * **Currency** — the currency for the payment * **Payment Reference** — appears on the transaction and the customer's bank statement (e.g. an invoice number, up to 18 characters) * **Order ID** *(optional)* — your own internal reference for reconciliation Create Payment Link dialog in the Quidkey Console Click **Create Payment Link** to generate your link. ## Step 2: Copy and share the link Once the link is created, click **Copy Link** and send it to your customer however you like, by email, SMS, messaging app, or include it in an invoice. Payment Link Created dialog with a Copy Link button Lost the link? You can recover it at any time from the **Payment Links** list in the Console. ## Step 3: Your customer completes the payment When your customer opens the link, they land on a fully branded checkout page where they enter their details and confirm the payment in their banking app. Quidkey predicts their bank automatically, or they can pick it from the list. The whole thing takes a few seconds, with no card details and no account creation. Walk through exactly what your customer sees, step by step. ## Step 4: Payment confirmed As soon as your customer approves the payment in their banking app, it is confirmed instantly. They see a confirmation page, and the payment appears in your Quidkey Console straight away. Branded payment successful confirmation page **That's it.** No chasing bank transfers. No waiting for payments to clear. Just a link. ## Next Steps What your customers see when they open a payment link Track status and manage your links Generate links programmatically with the Quidkey API Payment Links overview and lifecycle # Create a Checkout Link Source: https://docs.quidkey.com/guides/payment-links/create Generate a shareable checkout URL and send it to your customer Create a checkout link in one API call, then share the URL with your customer via any channel. ## Prerequisites * A Quidkey merchant account with `client_id` and `client_secret` * An active access token (see [Authentication](/api-reference/introduction#authentication)) ## Step 1: Authenticate Get an access token using your credentials. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/token' \ -H 'Content-Type: application/json' \ -d '{ "client_id": "your-client-id", "client_secret": "your-client-secret" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: 'your-client-id', client_secret: 'your-client-secret' }) }); const { data } = await response.json(); const accessToken = data.access_token; ``` ```python Python theme={null} import requests response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/token', json={ 'client_id': 'your-client-id', 'client_secret': 'your-client-secret' } ) access_token = response.json()['data']['access_token'] ``` ## Step 2: Create the Checkout Link Call `POST /api/v1/payment-links` with the payment details. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-links' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "order": { "amount": 5000, "currency": "EUR", "payment_reference": "INV-2024-001" } }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/payment-links', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ order: { amount: 5000, // €50.00 in cents currency: 'EUR', payment_reference: 'INV-2024-001' } }) }); const { data } = await response.json(); console.log('Payment link URL:', data.payment_link_url); console.log('Expires at:', data.expires_at); ``` ```python Python theme={null} response = requests.post( 'https://core.quidkey.com/api/v1/payment-links', headers={'Authorization': f'Bearer {access_token}'}, json={ 'order': { 'amount': 5000, # €50.00 in cents 'currency': 'EUR', 'payment_reference': 'INV-2024-001' } } ) data = response.json()['data'] print(f"Payment link URL: {data['payment_link_url']}") print(f"Expires at: {data['expires_at']}") ``` ### Response ```json theme={null} { "success": true, "data": { "link_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "payment_link_url": "https://core.quidkey.com/payment-link/a1b2c3d4e5f6...", "expires_at": "2024-04-07T12:00:00.000Z", "status": "active" } } ``` Save the `payment_link_url`. This is the URL you'll share with your customer. The token in the URL is only returned once at creation time. ## Step 3: Share the Link Send the `payment_link_url` to your customer through any channel: * **Email**: include in invoice emails or payment reminders * **SMS**: send a short message with the link * **Messaging apps**: WhatsApp, Telegram, or any chat platform * **In-person**: display as text or generate a QR code When the customer clicks the link, they'll see a Quidkey-hosted checkout page where they can complete the payment. See [Checkout Experience](/guides/payment-links/checkout-experience) for details. ## Request Body Reference | Field | Type | Required | Description | | --------------------------- | ------- | ----------- | ---------------------------------------------------------------------------------------------- | | `order.amount` | integer | Yes | Amount in minor units (cents). `5000` = €50.00 | | `order.currency` | string | Yes | ISO 4217 currency code (e.g., `EUR`, `GBP`) | | `order.payment_reference` | string | Yes | Up to 18 characters. Appears on the customer's bank statement. | | `order.order_id` | string | No | Your internal order identifier for reconciliation | | `order.locale` | string | No | BCP-47 locale tag (e.g., `en`, `pt`, `es`). Default: `en` | | `merchant_id` | string | No | Required for Partner authentication. UUID of the target merchant. | | `metadata` | object | No | Arbitrary key-value pairs to attach to the link | | `redirect_urls` | object | No | Custom redirect URLs after payment. See [Redirect URLs](#redirect-urls). | | `redirect_urls.success_url` | string | Conditional | URL to redirect customer to after successful payment. Required if `redirect_urls` is provided. | | `redirect_urls.failure_url` | string | Conditional | URL to redirect customer to after failed payment. Required if `redirect_urls` is provided. | **Amount format:** Use minor units (cents). `1000` = €10.00, `5000` = €50.00. This is the same format used by Stripe and the Embedded Flow. ## Redirect URLs By default, after payment customers are redirected to a Quidkey-hosted thank you or failure page. To send customers back to your own site, provide custom redirect URLs at creation time. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/payment-links' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "order": { "amount": 5000, "currency": "EUR", "payment_reference": "INV-2024-001" }, "redirect_urls": { "success_url": "https://yoursite.com/payment/success", "failure_url": "https://yoursite.com/payment/failure" } }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/payment-links', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ order: { amount: 5000, currency: 'EUR', payment_reference: 'INV-2024-001' }, redirect_urls: { success_url: 'https://yoursite.com/payment/success', failure_url: 'https://yoursite.com/payment/failure' } }) }); ``` ```python Python theme={null} response = requests.post( 'https://core.quidkey.com/api/v1/payment-links', headers={'Authorization': f'Bearer {access_token}'}, json={ 'order': { 'amount': 5000, 'currency': 'EUR', 'payment_reference': 'INV-2024-001' }, 'redirect_urls': { 'success_url': 'https://yoursite.com/payment/success', 'failure_url': 'https://yoursite.com/payment/failure' } } ) ``` When custom redirect URLs are provided, Quidkey appends query parameters so you can correlate the redirect on your side: | Parameter | Description | | ------------------- | --------------------------------------- | | `status` | `success` or `failed` | | `payment_reference` | The payment reference from the link | | `order_id` | Your order ID (if provided at creation) | For example, after a successful payment the customer would be redirected to: ``` https://yoursite.com/payment/success?status=success&payment_reference=INV-2024-001 ``` Both `success_url` and `failure_url` must be provided together. You cannot set only one. If `redirect_urls` is omitted, customers are redirected to Quidkey's default pages. Webhooks still fire regardless of redirect URL configuration. Custom redirects only affect where the customer's browser is sent. Your backend still receives the webhook notification as normal. See [After Payment](/guides/payment-links/after-payment) for webhook details. ## Link Types By default, payment links are **single-use**: they transition to `USED` after a customer completes payment. You can also create **reusable** links that stay active for multiple payments. Link type configuration (`single_use` vs `reusable`) is currently set at the system level. Contact support if you need reusable links for your use case (e.g., donation pages, recurring invoices). ## Link Expiry Payment links expire after **7 days** by default. The expiry timestamp is returned in the `expires_at` field of the creation response. When a customer opens an expired link, the checkout page displays an expiry message instead of the payment form. See [Link Lifecycle](/guides/payment-links/overview#link-lifecycle) for all status transitions. ## Next Steps See what your customers see when they open a payment link Track status, handle webhooks, and manage your links Full endpoint documentation with interactive playground Payment Links overview and lifecycle # Payment Links Source: https://docs.quidkey.com/guides/payment-links/overview Collect payments through a Quidkey-hosted checkout page. No frontend code required Payment Links let you collect bank-to-bank payments through a Quidkey-hosted checkout page. Create a link, from the Console with no code or via the API, send it to your customer via email, SMS, or any messaging channel, and get paid. No frontend integration needed. Create and share a link from the Console, no code required Create and share your first checkout link via the API What your customers see when they open a payment link Track status, handle webhooks, and manage links Full endpoint documentation with interactive playground ## When to Use Payment Links Payment Links and the Embedded Flow serve different integration needs: | | **Payment Links** | **Embedded Flow** | | ----------------------- | ---------------------------------------------------- | --------------------------------------- | | **Best for** | Invoicing, ad-hoc payments, no-code scenarios | E-commerce checkouts, in-app payments | | **Integration effort** | API call to create link, then share the URL | Embed iframe, handle postMessage events | | **Customer experience** | Quidkey-hosted checkout page | Inline checkout on your site | | **Frontend code** | None | HTML/JavaScript for iframe | | **Use case** | B2B invoices, service payments, cross-border pay-ins | Online stores, subscription platforms | **Already using the Embedded Flow?** You can use both. Payment Links are ideal for scenarios where you need to collect a payment outside your checkout page, like sending an invoice link via email. ## How It Works ```mermaid theme={null} sequenceDiagram autonumber actor Merchant participant API as Quidkey API actor Customer participant Checkout as Checkout Page participant Bank as Customer's Bank Merchant->>API: POST /payment-links (amount, currency, reference) API-->>Merchant: { payment_link_url } Merchant->>Customer: Share link (email, SMS, chat) Customer->>Checkout: Click payment link Checkout->>API: Fetch link details API-->>Checkout: Amount, merchant, reference Customer->>Checkout: Fill details & select bank Customer->>Bank: Authenticate & approve payment Bank-->>API: Payment callback API-->>Merchant: Webhook notification ``` ## Link Lifecycle Every payment link has a status that tracks its progress: | Status | Meaning | | ------------- | ---------------------------------------------------------------------------------- | | **ACTIVE** | Ready for use. Customers can open the link and complete payment. | | **USED** | Payment completed (single-use links only). The link can no longer accept payments. | | **EXPIRED** | Past its expiry time. Default expiry is 7 days, configurable at creation. | | **CANCELLED** | Manually cancelled via API. | **Single-use vs Reusable:** By default, links are single-use: they transition to USED when a customer completes payment. Set `link_type: "reusable"` to create links that stay ACTIVE for repeated payments (useful for donation pages or recurring invoices). ## Key Features * **Shareable URLs**: send via any channel: email, SMS, WhatsApp, messaging apps * **Hosted checkout page**: Quidkey-branded checkout page, no frontend code needed. By default, customers see a Quidkey thank you or failure page after payment. Optionally redirect them back to your own site with custom `redirect_urls`. * **Configurable expiry**: default 7 days, or set a custom duration * **Single-use and reusable**: one-time payment links or persistent links for repeated use * **View tracking**: see how many times a link has been opened * **Recoverable URLs**: copy the link URL at any time from the Console or API (encrypted token storage) * **Link-to-transaction**: when a link is used, navigate directly to the resulting transaction ## Next Steps Follow the [Create a Checkout Link](/guides/payment-links/create) guide to generate and share your first link. See [what your customers see](/guides/payment-links/checkout-experience) when they open a payment link. Learn how to [monitor payment status](/guides/payment-links/after-payment), handle webhooks, and list your links. # Export Transactions Source: https://docs.quidkey.com/guides/payouts/export Download transaction data as CSV or Excel for payout reconciliation Export the transactions behind any payout batch as **CSV** or **Excel** (`.xlsx`). The export carries every field your finance team needs to reconcile against bank statements and accounting records: amounts, fee breakdowns, exchange rates, refund status, and timestamps. Both formats are produced by the same transaction search endpoint. The output format is chosen by the `Accept` header; the request body selects which transactions you want. That makes reconciliation flexible: filter by payout batch for monthly close, or by date range to generate quarterly reports. ## Choosing CSV or Excel Best for scripts, accounting imports (Xero, QuickBooks), and pipelines. Plain text, easy to diff and grep. Best for finance teams opening files directly. Numeric columns keep their formatting, no import wizard. ## Exporting from the console Navigate to **Payouts** in the Console and click the batch you want to export. Click the **Export** button on the detail page and choose either **CSV** or **Excel**. The download starts immediately. Files are named `payout-{batchId}-transactions.csv` or `.xlsx` on console exports. When you export from the transactions search page directly, the default filename is `transactions-{YYYY-MM-DD}.{csv|xlsx}`. The same **Export** dropdown is on the transactions search page itself. Any filter you have applied (status, date range, currency, customer email) is honoured. That means you can generate a targeted export without needing a payout batch. ## Exporting via API Both formats live on the existing transaction search endpoint. You choose the format with the `Accept` header and the rows with the request body. ### Endpoint ``` POST /api/v1/merchants/{merchantId}/transactions ``` ### Headers | Header | Value | Purpose | | --------------- | ------------------------------------------------------------------- | -------------------- | | `Authorization` | `Bearer ` | Standard API auth | | `Content-Type` | `application/json` | Request body is JSON | | `Accept` | `text/csv` | Returns CSV | | `Accept` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | Returns XLSX | The `Accept` match is case-insensitive. Omitting `Accept` (or sending `application/json`) returns the standard paginated JSON response instead. ### Request body Any filter supported by transaction search works. For payout reconciliation, filter by batch ID: ```json theme={null} { "search": { "payout_batch_id": { "eq": "550e8400-e29b-41d4-a716-446655440000" } } } ``` Other common filters: `status`, `created_at` range, `currency`, `customer_email.contains`, `order_id.contains`. Combine them freely. See the [API reference](/api-reference/introduction) for the complete search schema. ### Example: CSV ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/merchants/{merchantId}/transactions' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Accept: text/csv' \ -d '{"search":{"payout_batch_id":{"eq":""}}}' \ -o payout-transactions.csv ``` ```javascript Node.js theme={null} const response = await fetch( `https://core.quidkey.com/api/v1/merchants/${merchantId}/transactions`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Accept': 'text/csv', }, body: JSON.stringify({ search: { payout_batch_id: { eq: payoutBatchId } }, }), } ); const csv = await response.text(); ``` ```python Python theme={null} import requests response = requests.post( f'https://core.quidkey.com/api/v1/merchants/{merchant_id}/transactions', headers={ 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', 'Accept': 'text/csv', }, json={'search': {'payout_batch_id': {'eq': payout_batch_id}}}, ) csv_content = response.text ``` ### Example: Excel (`.xlsx`) Swap the `Accept` header and treat the response body as binary: ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/merchants/{merchantId}/transactions' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Accept: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' \ -d '{"search":{"payout_batch_id":{"eq":""}}}' \ -o payout-transactions.xlsx ``` ```javascript Node.js theme={null} import { writeFile } from 'node:fs/promises'; const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const response = await fetch( `https://core.quidkey.com/api/v1/merchants/${merchantId}/transactions`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Accept': XLSX_MIME, }, body: JSON.stringify({ search: { payout_batch_id: { eq: payoutBatchId } }, }), } ); const buffer = Buffer.from(await response.arrayBuffer()); await writeFile('payout-transactions.xlsx', buffer); ``` ```python Python theme={null} import requests XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' response = requests.post( f'https://core.quidkey.com/api/v1/merchants/{merchant_id}/transactions', headers={ 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', 'Accept': XLSX_MIME, }, json={'search': {'payout_batch_id': {'eq': payout_batch_id}}}, ) with open('payout-transactions.xlsx', 'wb') as f: f.write(response.content) ``` The response carries `Content-Disposition: attachment; filename="..."` for browser downloads. Server-side callers can ignore it and name the file themselves. ## Response columns Each row represents one transaction that matched the search filters. | # | Column | Description | Example | | -- | ---------------------- | ------------------------------------------------------------- | ------------------------------ | | 1 | **Transaction Number** | Unique transaction identifier | `TXN-20240401-001` | | 2 | **Status** | Normalised merchant-facing status | `Completed` | | 3 | **Amount** | Original transaction amount in decimal | `50.00` | | 4 | **Currency** | Original currency (ISO 4217) | `EUR` | | 5 | **Converted Amount** | Amount after conversion to payout currency | `43.50` | | 6 | **Payout Currency** | Currency of the payout batch | `GBP` | | 7 | **Exchange Rate** | Rate applied (empty for same-currency payouts) | `0.87` | | 8 | **Fee Total** | Sum of all fees deducted | `1.25` | | 9 | **Fee Currency** | Currency the fees were charged in | `GBP` | | 10 | **Fee Details** | Per-fee breakdown, pipe-separated | `processing:0.75\|scheme:0.50` | | 11 | **Refund Status** | `Refunding`, `Partially Refunded`, `Fully Refunded`, or empty | `Partially Refunded` | | 12 | **Refunded Amount** | Total refunded so far, in the original currency | `10.00` | | 13 | **Customer Email** | Customer email address (if provided) | `jane@example.com` | | 14 | **Order ID** | Your internal order reference | `ORD-12345` | | 15 | **Created At** | ISO 8601 timestamp | `2024-04-01T10:30:00Z` | | 16 | **Paid Out At** | ISO 8601 timestamp (empty until the payout executes) | `2024-04-05T14:00:00Z` | **Amounts are decimal.** The export converts minor units to decimal (`5000` cents becomes `50.00`), which differs from the JSON API where amounts stay in minor units. This is intentional: exports are read by humans and accounting tools, so the representation matches what you'd expect to see in a ledger. **Refund columns** are populated only for payment transactions with completed refunds. For refund transactions themselves, or payments with no refunds, both columns are blank. ## Understanding fees The **Fee Total** column is the sum of every fee for the transaction. The **Fee Details** column gives the breakdown as `type:amount` pairs separated by pipes (`|`). Example: a `Fee Details` value of `processing:0.75|scheme:0.50` resolves to: | Fee type | Amount | | ---------- | -------- | | Processing | 0.75 | | Scheme | 0.50 | | **Total** | **1.25** | ## Row limits and truncation An export is capped at **10,000 rows per request**. When the filter matches more than that, the export still returns (with the first 10,000 rows) and sets two response headers so callers know it happened: | Header | Value | Meaning | | -------------------- | ------- | ------------------------------ | | `X-Export-Truncated` | `true` | The match exceeded the row cap | | `X-Export-Total` | Integer | The full match count | The console surfaces truncation as a warning toast after the download completes. API callers should inspect the headers and, if truncated, narrow the filter window (by date, status, or currency) and retry until each window fits under the cap. Never assume an export is complete without checking `X-Export-Truncated`. A truncated payout export that's silently imported into accounting will under-report revenue. ## Tips for reconciliation Use the **payment reference** from the payout batch detail page. This reference appears on your bank statement and uniquely identifies the transfer. The **Order ID** column maps to the `order_id` you provided when creating the payment request. Use this to match exported rows to your invoices or order management system. For cross-border payments, **Amount** and **Currency** show the original transaction values. **Converted Amount** and **Payout Currency** show what was actually paid out. **Exchange Rate** is the rate applied at conversion time, which may differ from spot rates on the payout date. **Refund Status** and **Refunded Amount** let you identify transactions that were fully or partially refunded within the batch. Subtract **Refunded Amount** from the gross **Amount** to get net revenue per transaction. ## Next steps How payouts work and the full reconciliation workflow Full endpoint and search schema documentation # Payout Reconciliation Source: https://docs.quidkey.com/guides/payouts/overview View transactions per payout batch and reconcile payments with your accounting Payout reconciliation lets you see exactly which transactions and fees make up each payout to your bank account. Every payout batch groups the transactions that were settled together, with a full breakdown of amounts, fees, and retention. Download all transactions in a payout batch as CSV or Excel View and manage payouts in your dashboard ## How payouts work When your customers complete payments through Quidkey, those transactions are collected and grouped into **payout batches**. Each batch represents a single transfer to your bank account. ```mermaid theme={null} flowchart LR A[Customer payments] --> B[Transactions collected] B --> C[Grouped into batch] C --> D[Fees deducted] D --> E[Payout to your bank] ``` Every batch includes: * **Total amount**: the gross sum of all transactions in the batch * **Fees**: processing fees deducted from the total * **Retained amount**: any amount held back based on your retention rate * **Payout amount**: the net amount transferred to your bank account ## Viewing payout batches Navigate to **Payouts** in the Console to see all your payout batches. You can filter by status: | Status | Meaning | | -------------- | ------------------------------------------------- | | **Pending** | Batch created, awaiting execution | | **Processing** | Payout transfer in progress | | **Completed** | Funds transferred to your bank account | | **Failed** | Transfer failed. Contact support if this persists | Click any batch to see the full detail page with the amount breakdown. ## Payout detail The detail page for each batch shows: * **Payment reference**: the unique reference for this payout, visible on your bank statement * **Amount breakdown**: total, payout, retained, and retention rate at a glance * **Transaction count**: how many transactions are included * **Timestamps**: when the batch was created and executed From the detail page you can export all transactions as CSV or Excel for reconciliation with your accounting system. ## Reconciliation workflow A typical reconciliation flow looks like this: Find the payout in your bank statement using the **payment reference**. This reference appears both in the Console and on your bank transaction. Click the matching batch in the Console to see the amount breakdown and verify the payout amount matches your bank statement. Download the batch as CSV or Excel to get a line-by-line breakdown of every transaction, including fees, exchange rates, and customer details. See [Export transactions](/guides/payouts/export) for the full field reference. Match individual transactions against your invoices or orders using the **Order ID** and **Transaction Number** fields. The **Order ID** field in the exported file corresponds to the `order_id` you provided when creating the payment. Use this to link payout transactions back to your internal records. ## Next steps Download and understand the transaction export Full endpoint documentation # Shopify Onboarding Source: https://docs.quidkey.com/guides/shopify/onboarding Connect your Shopify store to Quidkey and enable Pay by Bank in your checkout This guide walks you through connecting your Shopify store to Quidkey and enabling Pay by Bank in your checkout. You can test Quidkey on your Shopify store without completing KYC or KYB. Full verification is only required when you're ready to go live. This guide picks up once you have a Quidkey account. If you haven't created one yet, start with the [Onboarding guide](/guides/onboarding/overview). ## Prerequisites * A Shopify store with admin access * A [Quidkey account](/guides/onboarding/overview) (no documents or contracts required) You can install the app, connect it, and run a full test payment without providing any business information. * Business registration details * Shareholder and director information * Proof of identity * Business bank account information (for payouts) This is the standard KYB/KYC verification required for regulated payment processing. ## Step 1: Install the Shopify App You can also open the app directly: [apps.shopify.com/quidkey-checkout](https://apps.shopify.com/quidkey-checkout). Click the **Shopify** icon on your Quidkey Dashboard. Shopify will open in a new tab showing the **Pay by Bank** app. Click **Install**. Shopify App Store page for Pay by Bank Shopify will show the permissions screen. Click **Install** again to approve. Shopify permissions screen for Pay by Bank After installation, Shopify will automatically redirect you to the **Connect Quidkey** page. Keep both Shopify and Quidkey tabs open, you'll connect them in the next step. ## Step 2: Connect Shopify to Quidkey After installation, Shopify redirects you to the **Connect Quidkey** page. You need to copy your credentials from the Quidkey Console and paste them into Shopify. In your Quidkey Console, open the **Credentials** tab and click **Generate** to create your Secret Key. Credentials tab in the Quidkey Console Client Secret popup shown in the Quidkey Console Your Client Secret is shown **only once**. Copy it immediately and paste it into Shopify. Paste the Client Secret into the **Client secret** field in Shopify, then click **Connect Quidkey**. Shopify Connect Quidkey page where you paste your Client ID and Client Secret ### Enable Test Mode and Activate Quidkey After connecting, Shopify will show your Quidkey payment provider settings. Test Mode is normally enabled by default. If not, enable it manually: 1. Scroll to **Test Mode** 2. Turn **Test mode** on 3. Click **Activate** This allows you to make test payments safely without affecting real orders. Activate Pay by Bank App with Test Mode enabled ## Step 3: Enable Pay by Bank Spotlight (Recommended) **Pay by Bank Spotlight** adds a small guidance indicator on your product pages. It's not a payment button; the actual Pay by Bank option appears on the checkout page. This indicator helps customers understand early that they can pay with their bank and increases conversion. Pay by Bank Spotlight indicator shown on a product page In your Shopify admin, go to **Online Store** > **Themes** and click **Customize** on your active theme. In the theme editor sidebar, open **App embeds**. Find **Pay by Bank Spotlight**, turn the toggle on, and click **Save**. Theme editor App embeds page showing Pay by Bank Spotlight toggle Open your staging store URL (`https://{your-store-name}.myshopify.com`), go to any product page, and check that the Pay by Bank indicator appears. ## Step 4: Test Your Checkout You can try Pay by Bank immediately, without completing KYC. To run a test payment, you must use your **Shopify staging URL** (`https://{your-store-name}.myshopify.com`), not your custom domain. Test payment methods are **only** shown on the staging URL. Go to `https://{your-store-name}.myshopify.com`. Add any product to your cart and proceed to checkout. You should see **Pay with your bank** as a payment option. Checkout page showing Pay with your bank available in test mode Choose any test bank and complete the simulated flow. Check your Quidkey dashboard to confirm the test order appears. If you see "Pay with your bank" at checkout and can complete a test payment, your integration is working correctly. ## Step 5: Add Your Business Information On your Dashboard you'll find a **Go Live** checklist with the steps required before you can receive real payments: 1. Complete business information 2. Add shareholders & directors 3. Add a bank account 4. Account activation Go Live checklist in the Quidkey Dashboard The [Onboarding guide](/guides/onboarding/overview) covers exactly what each step asks for. Verification typically completes in minutes once all details are submitted. Once approved: * Live payments will be enabled in your Quidkey account * Payouts will be activated * Your pricing will be applied ## Step 6: Start Accepting Payments Once your business has been approved you can switch off Test Mode and start accepting customer payments. In your Shopify admin, open the Pay by Bank app settings. Toggle Test Mode off. Your store is now live and ready to accept real Pay by Bank payments. ## Troubleshooting * Confirm **Shopify Payments** is enabled on your store * Check that your Client Secret is valid and correctly pasted * Try a private/incognito window to remove cached settings * Make sure you're using the staging URL (`{store}.myshopify.com`) for test mode * Verify the Client Secret in Shopify matches your Quidkey Console * Confirm the app is installed and connected correctly * Try disconnecting and reconnecting the app * Pricing is applied after KYC/KYB verification is complete * In test mode, default pricing is used ## FAQ No. KYC is only required when you want to go live and receive real payouts. You can fully test the integration without any verification. Yes. Quidkey works alongside any other payment gateway on Shopify. Customers choose their preferred method at checkout. Usually minutes, depending on the documents provided. We'll notify you once verification is complete. Payouts are sent to your designated business bank account after KYC approval. Payout frequency and timing depend on your plan. Yes. Use [Payment Links](/guides/payment-links/overview) to collect a Pay by Bank payment with just a shareable link, ideal for invoices or ad-hoc charges. You can [create one from the Console](/guides/payment-links/console) with no code. ## Support Questions? We're here to help: * Email: [support@quidkey.com](mailto:support@quidkey.com) * Slack: We can create a shared channel for ongoing communication * Video calls: Contact [support@quidkey.com](mailto:support@quidkey.com) to schedule one # Quidkey Documentation Source: https://docs.quidkey.com/index Quidkey: global clearing house, optimized for modern payment workflows. Quidkey Hero Light Quidkey Hero Dark Create your merchant account to get client\_id and client\_secret Get your first Quidkey payment working in 10 minutes ## Welcome to Quidkey Quidkey is a global clearing house optimized for the modern world of payments. Our mission is simple: to make it easy for businesses everywhere to get paid instantly through direct account to account payments. Our AI bank affiliation algorithm creates last mile efficiencies through payment rail interoperability - resulting in more control, faster money movement, lower costs, and smoother global payment management. Quidkey’s technology allows businesses to automate and customize end-to-end payment workflows - utilizing bank prediction, local payment collection, settlement, FX, payouts, and reporting. ## Why Use Quidkey? Significantly reduce payment processing costs compared to card payments - typically 70-80% savings Customers authenticate payments directly in their bank app - the bank verifies identity, eliminating fraud risk Embedded directly in your checkout - customers never leave your site Funds settle faster than card payments with real-time payment schemes Add alongside your existing Stripe integration - no changes to Stripe code required Support SEPA, Faster Payments, Multibanco, and more payment schemes automatically Quidkey works alongside Stripe - customers can choose between card payments or bank transfers. Quidkey automatically predicts and pre-selects the customer's bank, so they pay with their trusted bank brand. Payment amounts and rewards can be updated dynamically for shipping costs, discounts, and promotional offers. ## How It Works Quidkey automatically predicts and pre-selects the customer's bank Customer sees their trusted bank pre-selected and ready to pay Customer is redirected into their bank to authorizes the payment securely Funds transfer directly to your account - you receive a webhook with payment confirmation Quidkey connects to multiple Open Banking providers (TPPs) and bank integration partners. We automatically select the best connection for each transaction to ensure high success rates and a smooth payment experience. ## Get Started Create your account, complete verification, and get approved for live payments Get your first payment working in 10 minutes with our quickstart guide Install the Shopify app and enable Pay by Bank. No code required Add Quidkey bank payments alongside your existing Stripe checkout Collect payments through a Quidkey-hosted checkout page. No frontend code required Explore all endpoints with interactive API playgrounds Try the full integration with test credentials ## When to Use Quidkey Perfect for online stores, marketplaces, and subscription services. Reduce transaction fees while maintaining seamless checkout experience. Ideal for transactions over €50 where card fees become significant. Common in travel, luxury goods, and B2B payments. Collect bank-to-bank payments through a Quidkey-hosted checkout page. No frontend code needed. Perfect for B2B invoices, service payments, and cross-border pay-ins. Quidkey currently supports account to account payments in the EU and UK, with the US available in Closed Beta. Support for Australia and Canada is coming soon. We are actively expanding our connectivity to more markets and banking networks. ## Need Help? Questions? Email us at [rabea@quidkey.com](mailto:rabea@quidkey.com) - we typically respond within one business day # Quickstart Source: https://docs.quidkey.com/quickstart Get your first Quidkey payment working in minutes Quidkey offers two ways to collect bank-to-bank payments. Choose the path that fits your use case, then follow the guide to get your first payment working. Create your merchant account to get your client\_id and client\_secret. **Time to complete:** 2-4 hours ## Choose Your Integration Install the Quidkey Shopify app and enable Pay by Bank at checkout. No code required. **Best for:** Shopify merchants **You'll need:** Shopify admin access Add Quidkey bank payments alongside your existing Stripe Payment Element. **Best for:** Merchants with an existing Stripe checkout **You'll need:** Backend + frontend code Generate shareable URLs that open a Quidkey-hosted checkout page. **Best for:** Invoicing, ad-hoc payments, no-code **You'll need:** One API call (no frontend code) **Not sure which to pick?** Use **Shopify** if you're on Shopify. Use the **Embedded Flow** if you have a Stripe checkout and want bank payments inline. Use **Payment Links** if you want to send a payment request without building a frontend. *** ## Authenticate (Both Paths) Regardless of which integration you choose, the first step is the same: get an access token using your credentials. ```bash cURL theme={null} curl -X POST 'https://core.quidkey.com/api/v1/oauth2/token' \ -H 'Content-Type: application/json' \ -d '{ "client_id": "your-client-id", "client_secret": "your-client-secret" }' ``` ```javascript Node.js theme={null} const response = await fetch('https://core.quidkey.com/api/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: 'your-client-id', client_secret: 'your-client-secret' }) }); const { access_token } = await response.json(); ``` ```python Python theme={null} import requests response = requests.post( 'https://core.quidkey.com/api/v1/oauth2/token', json={ 'client_id': 'your-client-id', 'client_secret': 'your-client-secret' } ) access_token = response.json()['data']['access_token'] ``` You should receive an `access_token` valid for 15 minutes. Save this for the next steps. Don't have credentials yet? [Try it in the API playground](/api-reference/endpoint/issue-token) first. No authentication required for testing! *** ## Next: Follow Your Path Now that you're authenticated, continue with your chosen integration: Create a payment token and embed the checkout iframe on your site Generate a shareable checkout URL in one API call *** ## More Resources Explore all endpoints with interactive playgrounds Configure webhooks to receive payment status updates ## Need Credentials? Sign up at [console.quidkey.com](https://console.quidkey.com) to get your `client_id` and `client_secret` for development and production environments. ## Support Questions? We're here to help: * Email: [rabea@quidkey.com](mailto:rabea@quidkey.com) * Response time: Typically within one business day