Developer Documentation

DynoPay API Reference

Everything you need to accept crypto payments, manage customer wallets, and track transactions programmatically.

Base URL

https://dynopay.com/api/user

create-payment.sh

POST /v1/payments
Authorization: sk_live_•••
{
  "amount": 79.00,
  "currency": "USD",
  "settle": "USDC"
}

201 · checkout_url returned

Checkout Payments

Hosted payment page — redirect customers to complete crypto payments in a few clicks.

Learn more →

Direct Crypto API

Full control over the UI. Get wallet addresses and QR codes via API and build your own flow.

Learn more →

Customer Wallets

Create customer wallets, add funds, debit balances, and track transactions.

Learn more →

Webhooks

Receive real-time notifications when payments are confirmed, pending, or underpaid.

Learn more →

Navigation

Overview
Getting Started
Try It Live
Authentication
Customers
Create Customer
Payments
Create Checkout Payment
Create Direct Crypto Payment
Embedded Checkout
Create Embedded Checkout Session
Elements Inline Widget
Create Elements Payment Intent
Select Currency (Elements)
Poll Elements Intent Status
Wallets
Add Funds to Wallet
Debit from Wallet
Get Wallet Balance
Transactions
List Transactions
Get Transaction Details
Verify Crypto Payment
Verify Payment by ID (recommended)
Testing (Sandbox)
Simulate Payment (test mode only)
Webhook Events
List Webhook Events
Resend a Webhook Event
Currencies
Get Supported Currencies
Customer Wallet Adjustments
Credit Customer Wallet
Debit Customer Wallet
Payment Statuses
Buy Button
Webhooks
Rate Limits
Error Handling

Overview

Dynopay provides a simple API to accept cryptocurrency payments, manage customer wallets, and track transactions. Payments are forwarded to your configured wallet as soon as they confirm on-chain, with transparent fees. Every endpoint on this page is a merchant endpoint — authenticated with your API key.

Base URL

All merchant endpoints are relative to the base URL below. A path shown as /createPayment is called at https://dynopay.com/api/user/createPayment.

bashhttps://dynopay.com/api/user

Ways to accept payments

Pick whichever fits your stack — every method settles the same way (crypto in, forwarded to your wallet, webhook fired on status change). No code? Start with a Payment Link or a Buy Button.

  • Hosted Checkout — call Create Checkout Payment, get a checkout_url, and redirect the buyer to a Dynopay-hosted page. Zero front-end work. See Payments.
  • Payment Links — create a reusable link in the dashboard (or via API) and share it by email, chat, or QR — e.g. dynopay.com/aBc123. Great for invoices and one-off requests, no site needed.
  • Buy Button — drop a <dynopay-buy-button> snippet on any page (Webflow, WordPress, plain HTML). A “Buy Now” button opens checkout in a modal. See Buy Button below.
  • Direct API — call Create Direct Crypto Payment to get an address + QR and render your own pay screen. Full control. See Payments.
  • Embedded Checkout (iframe) — mount the full checkout inside your page with embed.js + a server-created session. See Embedded Checkout.
  • Elements — render the pay UI (currency picker, address, QR, live status) directly in your DOM, no iframe. See Elements Inline Widget.
  • Webhooks — required for reliable fulfilment: Dynopay POSTs your server the moment a payment’s status changes. See Webhooks.

Multiple brands, one account

Run several businesses or brands from a single Dynopay login — each with its own wallets, checkout and settlement. Pass a company_id when creating payments, links or keys to scope them to a specific brand, and switch between brands in the dashboard with one click.

When to use each section

  • Customers — optional. Create a customer to track payments and balances per buyer. Skip it for one-off “userless” checkouts.
  • Payments — the core of the API. Create a hosted checkout or a direct crypto payment and the buyer pays in crypto.
  • Wallets — top up, debit, and check a customer’s wallet balance.
  • Customer Wallet Adjustments — credit or debit a customer’s store-credit balance programmatically (refunds, rewards, fees).
  • Transactions — look up payment and transaction history.
  • Currencies — list the cryptocurrencies you can accept.
  • Webhooks — get notified the moment a payment’s status changes.

A typical payment, end to end

1

Create a payment

Call Create Checkout Payment with your API key and an amount. You get back a checkout URL.

2

Send the buyer to checkout

Redirect the buyer to the returned URL. They pick a cryptocurrency and pay.

3

Funds forward to your wallet

Once the payment confirms on-chain, funds are forwarded to your configured wallet, minus transparent fees.

4

Get notified via webhook

Dynopay posts a webhook to your server with the final status, so you can fulfil the order automatically.

Quick Integration

Most integrations only need two API calls: Create Customer → Create Payment. The customer pays in crypto, and funds forward to your wallet once the payment confirms.

Getting Started

Integrate Dynopay in just two steps — no customer creation needed:

1

Get your API Key

Go to your Dynopay dashboard → API section → "Create New Key". You'll receive an API key for authenticating requests.

2

Create a Payment

Use the Checkout Payment or Direct Crypto Payment endpoint with just your API key. No customer creation needed. The customer pays in crypto and funds forward to your wallet once the payment confirms.

Quick Example (Userless — API Key Only)

bash# Create a checkout payment — just your API key, no customer setup!
curl -X POST https://dynopay.com/api/user/createPayment \
  -H "x-api-key: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"amount": 50, "redirect_uri": "https://yoursite.com/thanks"}'

# Or create a direct crypto payment with QR code:
curl -X POST https://dynopay.com/api/user/cryptoPayment \
  -H "x-api-key: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"amount": 25, "currency": "BTC", "redirect_uri": "https://yoursite.com/done"}'

# Optional: Create a customer for per-customer tracking
curl -X POST https://dynopay.com/api/user/createUser \
  -H "x-api-key: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"name": "Jane Smith", "email": "jane@example.com"}'

Try It Live

Run this request right now — no signup and no API key setup. It hits our public sandbox endpoint, returns an ephemeral payment link and never touches real funds or your data. Rate-limited per IP.

Request

bashcurl -X POST "https://dynopay.com/api/public/sandbox/payment-links" \
  -H "Authorization: Bearer dyno_sk_sandbox_demo_9f621db8" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 49.99,
    "currency": "USD",
    "description": "Pro Plan – Monthly",
    "customer_email": "customer@example.com"
  }'

Sample response

json{
  "object": "payment_link",
  "id": "plink_sandbox_2727324c61ebfb8b",
  "livemode": false,
  "sandbox": true,
  "status": "awaiting_payment",
  "amount": 49.99,
  "currency": "USD",
  "checkout_url": "https://dynopay.com/pay/…",
  "expires_at": "2026-07-05T16:14:12.536Z",
  "supported_chains": ["USDT-TRC20", "USDT-ERC20", "USDT-BEP20"]
}

The sandbox key dyno_sk_sandbox_demo_9f621db8 is public and only works on this endpoint. When you are ready to go live, create your own API key in the dashboard and switch to the real endpoints below — the request shape is the same. You can also open the live checkout demo to see what your customers experience.

Authentication

Your API key is all you need. Every server-side endpoint authenticates with a single x-api-key header — no customer login and no token exchange. Two other key types exist only for specific cases:

API Key — all you need

Send your secret x-api-key header with every request. It powers everything — payments, checkout, wallets and transactions. Keep it server-side; never expose it in a browser.

x-api-key: dpk_live_Ab3k…

Keys look like dpk_live_… / dpk_test_… and are shown once when created or regenerated — Dynopay stores only a one-way hash, so a key can never be recovered from our systems. Lost it? Regenerate from Developer › API keys; the old key stops working immediately.

Publishable Key (browser)

Only for Embedded Checkout and Elements, which run in your customer's browser. Use your domain-restricted publishable key (pk_live_…) there — never your secret API key.

# Browser only
pk_live_xxxxxxxxxxxx

Customer Token (optional)

Advanced and rarely needed. A few endpoints accept an optional customer Bearer token to scope wallet balances and history to one customer. You obtain it from POST /createUser. Omit it and the API runs in userless mode.

x-api-key: your_api_key
# Optional (advanced):
Authorization: Bearer {token_from_createUser}

Customers

POST

/api/user/createUser

Create Customer

API Key

Payments

Payment lifecycle

A crypto payment returns a single-use deposit address (and QR). The customer sends the crypto; once the network reaches the required confirmations for that asset the payment moves pending → confirmed → settled and funds are forwarded to your wallet. The deposit window is 24h — after that an unpaid payment goes expired.

  • Underpaid — the address stays open for the remainder; payment.underpaid reports how much arrived and how much is still owed.
  • Overpaid — the payment settles and the entire excess is credited to you (the fee is charged once, on the expected amount).
  • XRP / RLUSD — you must display the destination_tag alongside the address, or the deposit cannot be credited.
  • Confirm fulfilment from the payment.settled webhook (or re-verify with GET /getPaymentStatus/:payment_id) — never the browser redirect.
POST

/api/user/createPayment

Create Checkout Payment

API Key
POST

/api/user/cryptoPayment

Create Direct Crypto Payment

API Key

Embedded Checkout

Embedded Checkout mounts the Dynopay payment UI inside your page. Two steps: create the session on your server (your secret key stays server-side), then mount it in the browser with the returned client_secret.

javascript// 1) SERVER — create a session with your SECRET api key
const r = await fetch('https://dynopay.com/api/user/embed/session', {
  method: 'POST',
  headers: { 'x-api-key': process.env.DYNOPAY_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ amount: 49.99, currency: 'USD', webhook_url: 'https://you.com/webhooks/dynopay' }),
});
const { client_secret } = (await r.json()).data;   // safe to send to the browser

// 2) BROWSER — load embed.js from your checkout origin, then mount inline
Dynopay.initEmbeddedCheckout({
  fetchClientSecret: async () => client_secret,
}).then((checkout) => checkout.mount('#dynopay-checkout'));
// ...or Dynopay.openCheckout({ fetchClientSecret }) to open it as a modal.

Confirm on the webhook, not the browser

The onComplete event is UX only — a customer can close the tab. Fulfil the order from the payment.settled webhook. Sessions expire after 24h; the client_secret is safe for the browser, your x-api-key is not.

POST

/api/user/embed/session

Create Embedded Checkout Session

API Key

Elements Inline Widget

Elements is an inline widget you style into your own checkout. Flow: create an intent on your server, mount the widget in the browser with your publishable key (pk_live_…), and the customer picks a currency — which reserves a deposit address (select-currency, idempotent). The SDK polls elements/status roughly every 5s; the intent expires after 24h.

Publishable key in the browser

Mount Elements with your domain-restricted pk_live_… publishable key — never your secret x-api-key. As always, confirm fulfilment from the payment.settled webhook, not the widget completion event.

POST

/api/embed/public/elements/intent

Create Elements Payment Intent

Publishable Key
POST

/api/embed/public/elements/select-currency

Select Currency (Elements)

Publishable Key
GET

/api/embed/public/elements/status?intent_id=pi_...

Poll Elements Intent Status

Publishable Key

Wallets

POST

/api/user/addFunds

Add Funds to Wallet

API Key
POST

/api/user/useWallet

Debit from Wallet

API Key
GET

/api/user/getBalance

Get Wallet Balance

API Key

Transactions

GET

/api/user/getTransactions

List Transactions

API Key
GET

/api/user/getSingleTransaction/:id

Get Transaction Details

API Key
GET

/api/user/getCryptoTransaction/:address

Verify Crypto Payment

API Key
GET

/api/user/getPaymentStatus/:payment_id

Verify Payment by ID (recommended)

API Key

Testing (Sandbox)

Test vs live

Your key prefix decides the environment — nothing else in your integration changes. A dpk_test_ key creates sandbox payments (no real crypto, no on-chain settlement); a dpk_live_ key creates real ones. Test and live payments, webhook secrets and event logs are fully isolated.

The test loop

  1. Create a payment with your dpk_test_ key (e.g. POST /cryptoPayment). It starts in pending.
  2. Advance it: call POST /simulatePayment/:payment_id (below), or click Simulate in Developer › API keys. It walks pending → confirmed → settled.
  3. Your endpoint receives the real, signed payment.pending / payment.confirmed / payment.settled webhooks — the same whsec_ secret and V2 signature spec as live.

Sandbox limits

Test keys can be capped for safety — an optional max amount and an allowed-currency list. A request above the cap, or a currency outside the list, is rejected with 400 sandbox_restriction. Raise the limits on the key, or use a dpk_live_ key for production amounts.

Going live

Swap your dpk_test_ key for a dpk_live_ key from Developer › API keys — the request shape and endpoints are identical. Live payments can never be advanced with Simulate (it returns 403).

POST

/api/user/simulatePayment/:payment_id

Simulate Payment (test mode only)

API Key

Webhook Events

GET

/api/user/events

List Webhook Events

API Key
POST

/api/user/events/:id/resend

Resend a Webhook Event

API Key

Currencies

GET

/api/user/getSupportedCurrency

Get Supported Currencies

API Key

Customer Wallet Adjustments

POST

/api/user/customers/:customerId/credit

Credit Customer Wallet

API Key
POST

/api/user/customers/:customerId/debit

Debit Customer Wallet

API Key

Payment Statuses

Every crypto payment carries a normalized payment_status. You get it back from every create call, from GET /getPaymentStatus/:payment_id, and inside the payment.* webhooks. Fulfil the order once it reaches settled — the convenience flag is_paid is true only when the status is settled.

StatusIn your wallet?Final?What it means
waitingNoNoPayment created — waiting for the customer to send crypto. Nothing detected on-chain yet.
pendingNoNoCrypto has been detected on-chain and is awaiting the required network confirmations.
confirmedOn-chainNoConfirmations reached — the crypto is received on-chain; forwarding to your merchant wallet comes next.
processingIn transitNoSettlement in progress — funds are being forwarded (and auto-converted, if enabled) to your wallet.
settledYesYesSuccess. Funds have been delivered to your merchant wallet and is_paid is true. Safe to fulfil the order.
underpaidPartialNoThe customer sent less than the requested amount. Collect the remainder or issue a refund.
failedNoYesThe payment could not be completed after all retries.
expiredNoYesThe payment window elapsed before sufficient funds arrived.
refundedNoYesFunds were returned to the sender.

Terminal statuses (settled, failed, expired, refunded) never change again — stop polling once you see one. Everything else is transitional.

Underpayments: how much is left to pay

When a customer sends less than the requested amount the payment goes underpaid and the deposit address stays open for the remainder during the grace period. GET /getPaymentStatus/:payment_id (and the payment.underpaid webhook) report exactly how much arrived and how much is still owed:

FieldWhat it is
amountFull crypto amount expected for the payment.
amount_receivedCrypto received so far. Equals the full amount once paid; the partial amount while underpaid.
amount_remainingCrypto still owed. 0 once fully paid; non-zero only while underpaid.
paid_amountAlias of amount_received (crypto received so far).
amount_received_baseamount_received expressed in the payment's base_currency (USD, EUR, …).
amount_remaining_baseamount_remaining expressed in the payment's base_currency (USD, EUR, …).

The customer can complete the payment by sending amount_remaining in the same coin to the same address before the grace period ends — no new payment is needed.

Confirming a payment: webhooks vs. polling

  • Prefer webhooks. Subscribe to payment.confirmed and payment.settled (plus payment.underpaid and payment.settlement_failed) to react in real time. Only fulfil the order once the status is settled.
  • Reconcile with the API. GET /getPaymentStatus/:payment_id is keyed on the immutable payment_id and returns the authoritative status from the database — still correct after the checkout session expires. Use it to re-verify a webhook, or catch up on anything you missed with GET /events.
  • If you must poll, call getPaymentStatus every few seconds, respect the 100 requests/minute limit (back off on 429), and stop at the first terminal status.
  • Do not rely on the browser alone. The post-payment redirect / onComplete event is UX only — a customer can close the tab. Server-side webhooks (or a reconciliation poll) are the source of truth.

Buy Button

The Buy Button is the fastest way to sell without writing back-end code. Create a button once in the dashboard, then paste a small HTML snippet anywhere — a landing page, Webflow, WordPress, a blog, even an email-linked page. Your shopper clicks Buy Now and the Dynopay hosted checkout opens in a modal (or inline). The amount is fetched securely from our server by button-id, so it can never be tampered with in the browser.

1 · Create a button

  1. Go to Developers → Buy Buttons in your dashboard.
  2. Set a label, a fixed price (or a min/max range so the customer chooses), and the currencies you accept.
  3. Copy the generated snippet. It already includes your publishable key (pk_live_…) and button id (btn_…).

2 · Paste the snippet

html<!-- Dynopay embed SDK — load once per page -->
<script src="https://checkout.dynopay.com/v1/embed.js"></script>

<!-- Paste the button anywhere on your page -->
<dynopay-buy-button
  publishable-key="pk_live_your_key"
  button-id="btn_xxxxxxxx"
  mode="modal"
  theme="dark"
></dynopay-buy-button>

Attributes

AttributeRequiredDescription
publishable-keyYesYour browser-safe key (pk_live_… or pk_test_…). Domain-locked and amount-capped — never your secret API key.
button-idYesThe btn_… id of the button you created. The price is resolved server-side from this id.
amountNoOnly for range/customer-chooses buttons — the default amount to pre-fill. Ignored for fixed-price buttons.
currencyNoPre-select a single crypto (e.g. BTC). Omit to let the buyer choose from your accepted coins.
modeNo"modal" (default) opens checkout in an overlay; "redirect" sends the buyer to the hosted page.
themeNo"dark" or "light" — match your page.

Fulfil on the webhook, not the button

The button is UX only. Always confirm the final payment via the payment.confirmed webhook (see below) before you deliver the product or mark the order paid.

Webhooks

Dynopay sends webhook notifications to your configured URL when payment events occur. You set the webhook_url when creating a payment, or configure a default in your company settings.

Event Types

EventDescriptionAction
payment.pendingCrypto deposit detected on the blockchain (unconfirmed)Show "payment received" to user — wait for confirmation
payment.confirmedPayment fully confirmed (sufficient blockchain confirmations)Fulfill the order / deliver the product
payment.underpaidPartial payment received (less than expected amount)Notify customer or wait for remainder during grace period

Webhook Payload

All webhook events are sent as POST requests with a JSON body to your configured URL.

json{
  "event": "payment.confirmed",
  "payment_id": "pay_abc123def456",
  "transaction_id": "txn_789xyz",
  "amount": 0.00042,
  "currency": "BTC",
  "base_amount": "25.00",
  "base_currency": "USD",
  "status": "completed",
  "customer_email": "jane@example.com",
  "merchant_id": 38,
  "destination_tag": null,
  "meta_data": { "order_id": "ORD-12345" },
  "timestamp": "2025-07-15T12:00:00.000Z"
}

Webhook Headers

HeaderDescription
Content-Typeapplication/json
X-DynoPay-EventThe event type (e.g. payment.confirmed)
X-Dynopay-Signature-V2(Recommended) t=<unix>,v1=<hex> — HMAC-SHA256 over "<t>.<rawBody>" (the exact bytes sent). Only present when a webhook secret is configured.
X-DynoPay-Signature(Legacy) HMAC-SHA256 over a re-serialised body — kept for backward compatibility.
X-DynoPay-TimestampUnix timestamp of when the webhook was sent
X-DynoPay-Webhook-IdUnique webhook delivery ID for idempotency

Signature Verification

Verify the signature header to ensure webhook requests are authentic and haven't been tampered with. Prefer X-Dynopay-Signature-V2: it is signed over the exact bytes on the wire, so you can verify it in any language without re-serialising the parsed JSON. Both headers are sent while you migrate; the legacy X-DynoPay-Signature will be removed after the migration window closes. Signatures are sent only when your endpoint has a signing secret — saving a webhook URL (or creating an API key) auto-generates a whsec_… secret (shown once); endpoints without one receive the payload unsigned.

Signing spec

  • Signed message — the exact string {t}.{rawBody}: the timestamp, a literal dot, then the raw request-body bytes. No webhook id, no re-serialisation.
  • HMAC key — your signing secret used verbatim: the full whsec_… string (its UTF-8 bytes). Do not strip the whsec_ prefix and do not base64-decode it.
  • Algorithm — HMAC-SHA256.
  • Encoding — lowercase hex (this is the v1= value).
  • Timestamp t — Unix seconds (the same value as the X-DynoPay-Timestamp header). Reject anything outside ±300s to stop replays.

Which secret signs which endpoint

Each endpoint is signed with its own secret. Your account / dashboard endpoint is signed with your account webhook secret (the whsec_… shown once when you save the URL). A per-payment webhook_url is signed with the secret in effect on that payment. Always verify a request with the secret that belongs to the endpoint receiving it.

Secret rotation & multiple signatures

When you rotate a signing secret we co-sign each webhook with both the new and the previous secret for a ~24-hour grace window, so one delivery's X-Dynopay-Signature-V2 header can carry several v1= values, e.g. t=…,v1=<new>,v1=<previous>. Your verifier must scan every v1= token and accept if any one matches — never parse just one (a naive Object.fromEntries on the comma-split keeps only the last, which is the old secret, and fails every webhook). The first v1= is always the current secret.

V2 (recommended)

javascriptconst crypto = require('crypto');

// Verify against the RAW request body. Reject anything older than ±300s (replay).
function verifyWebhookV2(rawBody, headerV2, secret, toleranceSec = 300) {
  const tokens = String(headerV2).split(',').map((s) => s.trim());
  const t = Number((tokens.find((p) => p.startsWith('t=')) || '').slice(2));
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const exp = Buffer.from(expected, 'hex');
  // During a secret rotation DynoPay sends MULTIPLE v1= values (current +
  // previous, ~24h grace). Match if ANY verifies — always scan them all,
  // never just parse a single v1 (Object.fromEntries would drop all but the last).
  return tokens
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const got = Buffer.from(p.slice(3), 'hex');
      return got.length === exp.length && crypto.timingSafeEqual(got, exp);
    });
}

// Express — capture the raw body so the bytes are byte-identical to what was signed.
app.post('/webhooks/dynopay', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString();
  const v2 = req.headers['x-dynopay-signature-v2'];

  if (v2 && !verifyWebhookV2(rawBody, v2, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(rawBody);
  // Fulfill based on event.event (payment.confirmed, payment.pending, ...)
  res.status(200).json({ received: true });
});

Test vector

Feed these exact inputs to your HMAC routine — if you get the same v1, your signing is byte-correct and any remaining failure is header parsing (see rotation note above).

textsecret        = whsec_test_secret_example
t             = 1700000000
raw_body      = {"event":"payment.settled","payment_id":"pay_123","amount":19.99}
signed string = 1700000000.{"event":"payment.settled","payment_id":"pay_123","amount":19.99}
v1 (hex)      = f49a2735cfb74e9d7a03537ff1af830cf6876f25cd1cb42fe4143bf2e5cb1c66

Replay & idempotency

Reject any delivery whose t is more than ±300s from now to block replays. The same event can also arrive more than once (delivery retries, or a manual resend), so treat X-DynoPay-Webhook-Id as an idempotency key: record the ids you have processed and skip duplicates.

V1 (legacy)

javascriptconst crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');
  return signature === expectedSignature;
}

// In your Express webhook handler:
app.post('/webhooks/dynopay', (req, res) => {
  const signature = req.headers['x-dynopay-signature'];
  const isValid = verifyWebhook(req.body, signature, process.env.WEBHOOK_SECRET);

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  switch (req.body.event) {
    case 'payment.confirmed':
      // Fulfill order
      break;
    case 'payment.pending':
      // Show pending status
      break;
    case 'payment.underpaid':
      // Notify customer
      break;
  }

  // Always return 200 to acknowledge receipt
  res.status(200).json({ received: true });
});

Retry Policy

If your endpoint returns a non-2xx status code (or times out), Dynopay retries delivery on a fixed schedule — 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then 24 h (7 retries over ~24 hours). After the final attempt the event is marked failed. You can list past deliveries with GET /events and re-send any of them with POST /events/:id/resend (or from the dashboard).

Webhook URL Priority

PrioritySourceWhen to Use
1stPer-payment webhook_url fieldDifferent webhook per payment or product
2ndAPI Key webhook settingsDifferent webhook per API integration
3rdCompany default settingsSame webhook for all payments

Rate Limits

Dynopay enforces rate limits to ensure platform stability. Limits are applied per IP address and per API key.

Endpoint CategoryLimitWindow
Payment creation30 requests1 minute
General API100 requests1 minute
Authentication (login)10 requests15 minutes
Webhook delivery200 requests5 minutes

When rate limited, the API returns HTTP 429 Too Many Requests with a Retry-After header indicating when you can retry.

Error Handling

All errors return the human-readable message plus a machine-readable error object — a stable type and code you can branch on, the offending param, a doc_url, and a request_id (also returned as a Request-Id response header — quote it when contacting support). The legacy success/message/errors fields are unchanged, so existing integrations keep working.

json{
  "success": false,
  "message": "Amount must be greater than or equal to 5",
  "errors": [{ "key": "amount", "error": "Invalid amount" }],
  "error": {
    "type": "invalid_request_error",
    "code": "amount_below_minimum",
    "message": "Amount must be greater than or equal to 5",
    "param": "amount",
    "doc_url": "https://dynopay.com/documentation#errors",
    "request_id": "req_8f3c2a1b"
  }
}

error.type is one of invalid_request_error, authentication_error, rate_limit_error or api_error.

StatusMeaning
400Bad Request — missing or invalid parameters
401Unauthorized — invalid or missing API key / token
403Forbidden — authenticated but not allowed (e.g. wrong role, or a publishable key used from a disallowed origin)
404Not Found — resource does not exist
429Too Many Requests — rate limit hit; retry after the Retry-After header
500Server Error — something went wrong on our side

Error codes

Branch on error.code — these stay stable even if we reword a message.

codeHTTPWhen it fires
api_key_missing401No x-api-key header on the request
api_key_invalid401API key not recognized or revoked
amount_invalid400amount is missing or not greater than 0
amount_below_minimum400amount is below the minimum (createPayment: 5)
currency_required400currency was omitted
currency_not_available400Requested currency has no wallet configured (see param)
no_wallet_configured400The merchant has no crypto wallet set up yet
parameter_missing400A required field is absent — see error.param
rate_unavailable500Could not fetch a conversion rate — safe to retry
sandbox_restriction400A test (dpk_test_) key exceeded its sandbox limits
invalid_idempotency_key400Idempotency-Key header is empty or too long
idempotency_key_reused409Idempotency-Key reused with a different request body
idempotency_request_in_progress409The first request with this key is still processing

Idempotent retries

Send an Idempotency-Key header on any POST (a unique value per logical request, e.g. a UUID) to make it safe to retry. If a network blip makes you retry, the same key + same body replays the original response — with an Idempotent-Replay: true header — so a payment is never created twice. Reusing a key with a different body returns 409 idempotency_key_reused; keys are remembered for 24 hours.

bashcurl https://api.dynopay.com/api/user/createPayment \
  -H "x-api-key: $DYNOPAY_API_KEY" \
  -H "Idempotency-Key: 8f14e45f-ce2a-4b7d-9a1e-1b2c3d4e5f60" \
  -H "Content-Type: application/json" \
  -d '{"amount": 25, "redirect_uri": "https://your-site.com/thanks"}'

From 0.5% · no chargebacks

Ready to ship?

Non-custodial, from 0.5%, no chargebacks — create your API key and take your first payment today.

View fees