Skip to main content
All articles
Technical11 min read

Match a payment to its order: orderId, externalId, metadata and the other identifiers

Match a payment to its order: who creates orderId, externalId, metadata or operationId, where each one comes back, through refunds and the CSV export.

To match a payment to its order, put your own reference in the payment's metadata field: it comes back to you verbatim in the payment event, and, for a checkout session, the event also carries your orderId. The externalId, on the other hand, exists so you never create the same resource twice; it does not come back in the event of a session or a link. This guide sorts out the seven identifiers of a payment — who creates them, what they are for, where they come back — then applies them to sessions, links, subscriptions and the hosted storefront, all the way to refunds and the CSV export line.

Match a payment to its order: the identifier table

ChariPay is a Moroccan payment platform, a PSP operated by Chari Money, a payment institution licensed by Bank Al-Maghrib. A payment there crosses several systems — your website, the payment page, our webhooks, the accounting export — and each one has its own identifier. Mixing them up is how "orphan" payments happen: the money is there, the order is still waiting. Here are the seven to know.

IdentifierCreated byUsed toComes back in
orderIdyou, when creating a sessioncarry your business reference, with no uniqueness constraintthe session's payment event
externalIdyou, on a link, a session, a subscription or a productnever create the same resource twice; unique per account and per environmentlink reads; a subscription's events; not in the event of a session or a link
metadatayoucarry your own free-form references: cart, order, channelverbatim, on reads and in the payment event
reference (pl_…)ChariPay, when a link is createdidentify the link and read its statethe creation response; GET /v1/payment-links/{reference}
sessionId (ps_…)ChariPay, when a session is createdidentify the session and read its statusthe creation response; GET /v1/payment-sessions/{sessionId}
operationIdChariPay, at payment timeidentify the payment, notably to refund itGET /v1/transactions; GET /v1/transactions/{operationId}
refundReferenceyou, when requesting a refundmake the refund replayable with no double debitGET /v1/refunds/{reference}, which accepts your refundReference

Two rules sum up the table. First: find your order through your metadata, echoed verbatim in the event; for a session, the event also carries your orderId. Second: the externalId of a session or a link does not come back in the event — it is for deduplication, not for matching; only subscriptions are the exception. What the externalId protects you from, and how, is explained in payment idempotency.

Payment metadata: a flat, light object, echoed verbatim

The metadata field is an object you attach when creating a link, a session, a subscription or a product. Its rules are strict:

  • flat: strings, numbers, booleans; no nested object and no array, or the create fails with a 400;
  • light: 4 KB at most;
  • at the root: sent once, at the root of the request, not inside config;
  • echoed verbatim: on reads and in the payment event, untouched.
Put inKeep out
your internal references: cartId, orderNumberthe customer's name, phone or email, which have their own fields
the sales channel: "source": "web" or "source": "whatsapp"a card number, a CVV, any card data
a store or register identifiera secret, a token, a key
a boolean business flag: "gift": truea nested object or a list

Why these precautions? Because your metadata comes back verbatim, in events that travel through your logs, your message queues and sometimes your analytics tools. Put nothing in it that you would not want to read in a log: opaque identifiers that only your database can tie to a person.

See it for yourself: create a sandbox session carrying some metadata, pay it with the test card, and read the event that reaches your endpoint. Start in test mode: three fields — name, work email, company — then an activation link received by email to choose your password, then the portal, where you create your own test key after re-authenticating. Test mode is free, with no time limit, and your business verification only gates production.

Checkout session: orderId and metadata in the event

A session carries your three identifiers from the moment it is created:

bash
curl -X POST 'https://api-psp.charipay.ma/v1/payment-sessions' \
  -H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-2026-0421-session' \
  -d '{
    "amount": 250.00,
    "orderId": "ORD-2026-0421",
    "externalId": "order-2026-0421",
    "config": {
      "customer": {
        "email": "amine.bennani@example.com",
        "firstName": "Amine",
        "lastName": "Bennani",
        "phone": "+212600000000"
      },
      "urls": {
        "accept": "https://votre-boutique.ma/paiement/succes",
        "decline": "https://votre-boutique.ma/paiement/echec",
        "notification": "https://votre-boutique.ma/webhooks/charipay"
      }
    },
    "metadata": { "cartId": "c_987", "source": "web" }
  }'

ORD-2026-0421 is your order reference, order-2026-0421 your deduplication key, and cartId and source your free-form references. The response contains only five fields — sessionId, sessionToken, verifyKey, expiresAt, checkoutUrl: store the sessionId next to the order, you will need it to read the session again. The creation fields are described in the Checkout sessions module of the API docs.

When the payment succeeds, the payment.succeeded event brings you your metadata, verbatim, and your orderId — or, if you set one, the value of config.urls.externalReference, which takes its place. The event's identifier and its type are not read from the body but from the Chari-Event-Id and Chari-Event-Type headers. In your receiver, the line that touches your order looks like this:

javascript
await orders.markPaid(event.metadata.cartId);   // your metadata, echoed verbatim

It fits into a complete receiver — signature verification on the raw body, deduplication, acknowledgment — described in getting your webhook integration right. The event body is flat JSON whose field list is not published: base your matching on your metadata, and look for the event's identifier in Chari-Event-Id, never anywhere else.

A link follows the same logic, with its own identifiers:

bash
curl -X POST 'https://api-psp.charipay.ma/v1/payment-links' \
  -H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-1042-link' \
  -d '{
    "amount": 149.90,
    "description": "Commande #1042",
    "singleUse": true,
    "externalId": "order-1042",
    "customerEmail": "amine.bennani@example.com",
    "customerPhone": "+212600000000",
    "metadata": { "cartId": "c_987" }
  }'

The response — 201, or 200 if you replay the same externalId — gives you the link's reference (pl_…) and its payUrl, the link to share. Store the reference next to the order: it is what lets you read the link's state with GET /v1/payment-links/{reference}.

When the customer pays by card, the payment event carries the link's metadata — here cartId — not its externalId. Your matching therefore relies on the metadata, or on the reference you kept. For a single-use link paid in cash at an agency, follow the link's state: as soon as the network confirms the deposit, the link turns "Paid" and you are notified in the portal; on the server side, read the link's state with GET /v1/payment-links/{reference}. The customer's side of that flow is detailed in collecting cash at an agency.

Subscriptions and the hosted storefront: two exceptions to know

Subscriptions. For a subscription, your externalId and your metadata come back in the subscription.payment_succeeded and subscription.payment_failed events: this is documented, and it is the only exception to the externalId rule. Each installment therefore maps directly to the subscription in your system, with no lookup table. Calls and events are detailed in subscriptions through the API.

Hosted storefront. An order paid by card on the hosted storefront comes with two events: order.paid, which reports the paid order, and the companion payment.succeeded, which carries the product's metadata. If your matching relies on metadata — a warehouse identifier, a catalog reference — read it in payment.succeeded, not in order.paid. The tool itself is presented in hosted storefront.

From payment to refund: finding the operationId

To refund, you have to point to the original payment. Prefer its operationId, the payment's identifier on the ChariPay side. You read it from GET /v1/transactions — a list you can filter by status, type, method, channel, from, to and search, walked with the limit / cursor / hasMore cursor — or from GET /v1/transactions/{operationId}, which details one transaction; GET /v1/transactions/{operationId}/timeline traces its history. Every movement carries the reference of the sale that explains it: identify your order's payment once, then keep its operationId next to the order so you never have to look for it again.

Without an operationId, the refund request accepts an externalId: it then means the payment's business reference — for a session, the value of your orderId, or that of config.urls.externalReference if you set it — never the idempotency externalId you gave the session. That is the trap: the same field name means two different things depending on the call.

Finally, your refundReference is derived from the order — refund-ORD-2026-0421-1 — never from a random draw: it is what makes the refund replayable, with 202 on creation and 200 on replay, then refund.succeeded or refund.failed at the end. The full procedure, from the portal or through the API, is in refunding a customer.

All the way to the CSV export line

Every account movement carries the reference of the sale, refund or payout that explains it; the portal separates amount paid, fees and net, and the CSV export keeps those references — from the portal or with GET /v1/transactions/export.csv (up to 10,000 rows; beyond that, split by period with from/to). Timestamps are in UTC; Morocco is UTC+1: your reconciliation tool must convert.

Three consequences for your matching:

  • a refund is a separate movement, linked to the original transaction, and it can reduce the next payout without a separate line on your bank statement;
  • the export through the API requires a key with the `transaction:export` permission: without it, the call returns 403 FORBIDDEN;
  • a payment made just after midnight, Moroccan time, shows up on the previous day in the raw export, as long as you do not convert.

The daily method — fees, payout timing, refunds — belongs to reconciling your collections: this guide stops at the data that makes it possible. The list and export parameters are in the Transactions module of the API docs.

Five matching mistakes to avoid

  1. 1Waiting for the `externalId` in the event. For a session or a link, it does not come back: match on metadata, and on orderId for a session.
  2. 2Nesting an object inside `metadata`. The create fails with a 400: keep a flat object made of strings, numbers and booleans.
  3. 3Reusing the same `orderId` for two orders. The API accepts it, since orderId carries no uniqueness constraint; your matching, however, becomes ambiguous.
  4. 4Refunding with a session's idempotency `externalId`. Use the operationId, or failing that the payment's business reference: your orderId, or config.urls.externalReference if you set it.
  5. 5Deduplicating on `Chari-Webhook-Id`. Deduplicate on Chari-Event-Id, not on Chari-Webhook-Id — the latter identifies the delivery, not the event.

Mistakes 1 and 5 share the same root: taking one identifier for another — the deduplication key for the order reference, the delivery identifier for the event's. The difference between preventing a duplicate and finding an order is developed in payment idempotency.

Frequently asked questions

What is the difference between orderId and externalId?

The orderId is your business reference for a session: it has no uniqueness constraint and the payment event carries it. The externalId is a deduplication key, unique per account and per environment: a create with an existing externalId returns the existing resource with 200. For a session or a link, the externalId does not come back in the event.

Does the externalId come back in the payment webhook?

Not for a session or a link: find your order through your metadata, echoed verbatim in the event; for a session, the event also carries your orderId. The only exception is subscriptions: your externalId and your metadata come back in subscription.payment_succeeded and subscription.payment_failed, as the API contract documents.

What can I put in a payment's metadata?

A flat object — strings, numbers, booleans — of 4 KB at most, with no nested object or array, or you get a 400. Put opaque references in it: cart identifier, order number, sales channel, store. Never put card data or secrets in it, and avoid personal data, since the content comes back verbatim in the events.

How do I find a payment's operationId?

With GET /v1/transactions, filtering by period, status, type or payment method, and walking the list with the cursor; or with GET /v1/transactions/{operationId} for the details. Every movement carries the reference of the sale that explains it: identify the payment once, then keep its operationId next to the order.

Keep the link's reference (pl_…) next to the order from the moment you create it. The payment event carries the link's metadata, not its externalId, and the link's state can be read at any time with GET /v1/payment-links/{reference}. For a cash payment at an agency, the link turns "Paid" and you are notified in the portal.

Where do I read a webhook event's identifier?

In the Chari-Event-Id header, which is stable across all attempts and all your endpoints; the event type is in Chari-Event-Type. Do not look for it in the body. Chari-Webhook-Id, for its part, identifies the delivery: it is not for deduplication. Your deduplication table should put its uniqueness constraint on Chari-Event-Id.

Do I need a different orderId for each session?

Not necessarily. The orderId has no uniqueness constraint: a new session created for the same order, after an expiry or a cancellation, can keep the same orderId. Its externalId, however, must change, or the API returns the old session; change the Idempotency-Key too, or the API returns 409 IDEMPOTENCY_CONFLICT. Two different orders should never share an orderId, or your matching becomes ambiguous.

Next step

Create a sandbox session and a sandbox link carrying some metadata, pay them with the test card, then find each identifier in the event, in GET /v1/transactions and in the export: Start in test mode. The accounting method that takes over from there is in reconciling your collections, and the refund procedure in refunding a customer.

Written by ChariPay team.

Read next

Technical5 min read

Never charge twice — idempotency explained

Payment idempotency explained: how the Idempotency-Key header and externalId stop you charging a customer twice when the network drops mid-call.

Ready to try it yourself?

Create your account and take test-mode payments today: it is free, with no approval to wait for. A question? Our team supports merchants and developers alike.

  • Free, no commitment
  • Test mode from sign-up
  • No approval to wait for