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.
| Identifier | Created by | Used to | Comes back in |
|---|---|---|---|
orderId | you, when creating a session | carry your business reference, with no uniqueness constraint | the session's payment event |
externalId | you, on a link, a session, a subscription or a product | never create the same resource twice; unique per account and per environment | link reads; a subscription's events; not in the event of a session or a link |
metadata | you | carry your own free-form references: cart, order, channel | verbatim, on reads and in the payment event |
reference (pl_…) | ChariPay, when a link is created | identify the link and read its state | the creation response; GET /v1/payment-links/{reference} |
sessionId (ps_…) | ChariPay, when a session is created | identify the session and read its status | the creation response; GET /v1/payment-sessions/{sessionId} |
operationId | ChariPay, at payment time | identify the payment, notably to refund it | GET /v1/transactions; GET /v1/transactions/{operationId} |
refundReference | you, when requesting a refund | make the refund replayable with no double debit | GET /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 in | Keep out |
|---|---|
your internal references: cartId, orderNumber | the 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 identifier | a secret, a token, a key |
a boolean business flag: "gift": true | a 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:
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:
await orders.markPaid(event.metadata.cartId); // your metadata, echoed verbatimIt 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.
Payment link: the link's reference and metadata
A link follows the same logic, with its own identifiers:
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
- 1Waiting for the `externalId` in the event. For a session or a link, it does not come back: match on
metadata, and onorderIdfor a session. - 2Nesting an object inside `metadata`. The create fails with a
400: keep a flat object made of strings, numbers and booleans. - 3Reusing the same `orderId` for two orders. The API accepts it, since
orderIdcarries no uniqueness constraint; your matching, however, becomes ambiguous. - 4Refunding with a session's idempotency `externalId`. Use the
operationId, or failing that the payment's business reference: yourorderId, orconfig.urls.externalReferenceif you set it. - 5Deduplicating on `Chari-Webhook-Id`. Deduplicate on
Chari-Event-Id, not onChari-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.
How do I know which order a payment link paid for?
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.