MercanPay Blog

Idempotent Invoices with order_id: Match Orders, Avoid Duplicates

Use order_id for idempotent invoice creation: match crypto invoices to orders, retry safely, handle 409 conflicts and reconcile payments with the API.

Published: November 5, 20266 min readTürkçe oku
Idempotent Invoices with order_id: Match Orders, Avoid Duplicates

Idempotent invoice creation is the quiet foundation of every reliable crypto checkout. When a payment lands you need to know exactly which order it closes, a retry after a network error must never open a second invoice, and your books have to match at the end of the month. In MercanPay all of this hangs on a single field: order_id. This guide explains how it works, how the idempotency rules behave and which mistakes to avoid.

What order_id is and why it matters

order_id is your own order number, sent when you create an invoice. It is optional and up to 128 characters long, but we recommend it for every API integration because it does three jobs at once:

  • Matching: order_id comes back unchanged in webhooks and API responses, so you look orders up by your own number rather than by invoice id.
  • Idempotency: sending the same order_id again returns the existing invoice instead of creating a new one.
  • Lookup: GET /v1/invoices/by-order/{order_id} fetches an invoice directly by your order number.

If you're new to the overall flow, start with what a crypto payment gateway is.

How idempotent invoice creation works

Picture this: the customer clicks "Pay", your server sends POST /v1/invoices, and the connection times out before a response arrives. Was the invoice created? You can't tell.

With an order_id the answer is simple: send the same request again. You get one of three outcomes:

Situation HTTP response Meaning
No invoice existed yet 201 Created A new invoice was created
Invoice exists with the same order_id and parameters 200 OK The existing invoice is returned
Invoice exists with the same order_id, different parameters 409 · order_id_conflict The request is rejected

Even two simultaneous requests, say from a double-clicked button, resolve to one invoice, so the customer never sees two different payment addresses.

When do you get 409 order_id_conflict?

If you reuse an order number with a different amount or coin, the API treats it as an error. Ask for a $25 invoice for order-1001 and later a $30 one, and the second call returns 409 order_id_conflict. That's deliberate: one order can't be paid at two different prices.

If the cart genuinely changed (items added, a coupon applied), use a new order number or a version suffix such as order-1001-v2.

Idempotent invoices in code

Here is a PHP helper that creates an invoice for an order and retries safely on transient errors:

<?php
require 'vendor/autoload.php';

use MercanPay\MercanPay;
use MercanPay\MercanPayException;

$zp = new MercanPay('https://mercanpay.com', getenv('MERCANPAY_API_KEY'));

function invoiceFor(MercanPay $zp, string $orderId, string $priceUsd): array
{
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        try {
            return $zp->createUsdInvoice($priceUsd, ['order_id' => $orderId]);
        } catch (MercanPayException $e) {
            $retryable = $e->getHttpStatus() === 0 || $e->getHttpStatus() === 429 || $e->getHttpStatus() >= 500;
            if (!$retryable || $attempt === 3) {
                throw $e;
            }
            sleep($attempt * 2); // same order_id: no duplicate invoice
        }
    }
}

A getHttpStatus() of 0 means the request never got a response (a network error). 429 and 5xx are also safe to retry with the same request. A 400 or 409 won't fix itself; correct the request instead.

In Python the same idea is create_invoice(price_usd="25.00", order_id="order-1001"). See the Python integration guide for a full example.

Choosing a good order number

A good order_id is:

  • Unique for each payment attempt in your store. Your database order id usually works.
  • Not secret: it isn't a password, but keep personal data such as emails or phone numbers out of it.
  • Stable: the same order always produces the same value. Generating a fresh random value on every request quietly disables idempotency.
  • Short and readable: order-1001 is easier to quote in a support conversation than a 100-character hash.

Expired invoices and new attempts

A request with an existing order_id returns that invoice even if it has expired. If the customer didn't pay in time and wants to try again, use a new attempt number:

order-1001      → first invoice (expired)
order-1001-r2   → second attempt

Model this on your side, too: one order can have several payment attempts, for example an orders table with related payment_attempts. We cover invoice lifetimes in crypto invoice expiry time.

Matching in the webhook

When a payment is final, the webhook carries your value in data.invoice.order_id. Look the order up by it, then run three checks:

  1. Verify the signature. See how to verify webhook signatures.
  2. Compare coin and amount. Do token and paid_amount match what you expect?
  3. Drop duplicates. The same event may arrive more than once. Store the event id, or only update the order if it isn't already paid.
UPDATE orders
   SET status = 'paid', invoice_id = :invoice_id
 WHERE id = :order_id AND status <> 'paid';

This conditional update processes the order once even if the event arrives twice. Retries are covered in depth in webhook retries and idempotent handling.

Extra context with metadata

order_id holds a single value. For more context, use metadata: a JSON object up to 4 KB that is never shown to the customer and comes back unchanged in webhooks and API responses:

$zp->createUsdInvoice('49.90', [
    'order_id' => 'order-1001',
    'metadata' => ['customer_id' => 42, 'plan' => 'pro'],
]);

Still, treat your own database as the source of truth. Metadata is a convenience, not a ledger.

Reconciliation: do the records agree?

The last step is regular reconciliation. If a webhook was missed while your server was down, the API closes the gap:

$page = $zp->listInvoices('paid', 20);
foreach ($page['data'] as $invoice) {
    // mark $invoice['order_id'] as paid if it isn't already
}
// next page: $zp->listInvoices('paid', 20, $page['next_before']);

To check a single order, getInvoiceByOrder('order-1001') is enough. A small hourly or daily job prevents most "I paid but my order is still open" tickets. For bulk checks from the panel, you can export payment reports as CSV, Excel or PDF; invoice exports include an order id column.

Common mistakes

  • Not sending order_id: a retry creates a duplicate invoice and the customer may see two links.
  • Generating a new random value per request: this effectively switches idempotency off.
  • Reusing an order_id after the amount changed: you'll get 409 order_id_conflict.
  • Fulfilling from the browser redirect: success_url parameters can be forged. Only a verified webhook or an API lookup should confirm an order.
  • Sharing one invoice across several orders: open one invoice per order so matching stays unambiguous.

FAQ

Is order_id required?

No. If you create the occasional invoice by hand in the panel you can leave it empty. For API integrations we strongly recommend it to prevent duplicates.

I run two stores with overlapping order numbers. Is that a problem?

order_id is unique within a merchant account. If one account serves two stores, add a prefix: shopA-1001, shopB-1001.

Should I store the invoice id or the order_id?

Both. The invoice id is handy for support and API lookups; the order_id links everything back to your system.

How do I tell whether a response is a new invoice or an existing one?

Check the status code: 201 means a new invoice was created, 200 means an existing one was returned.

Get started with MercanPay

Solid order matching is the backbone of a smooth crypto checkout. Apply for a merchant account, read the API documentation for every field, and build your integration idempotent from day one.

#API#order_id#Integration#Webhook

Related posts

Start accepting crypto payments in minutes

Accept USDT and TRX (TRC20). Fees from 0.4%, a hosted payment page, API and webhooks.