Customer identity binding
Why every session names your customer, the binding ceremony, the review queue, and the three endpoints that manage a binding
Every checkout session belongs to one of your customers, and Coinland Pay makes that relationship
explicit and permanent: the first time a customer of yours pays, the Coinland account they pay with is
bound to your customer.id. From then on, only that Coinland account can pay sessions for that
customer, only you can sever the link, and the customer cannot change it themselves.
This is an AML requirement, not an optimisation. A payment on this rail is always traceable to a named customer of a named business, and the binding is what makes "the same person" a fact rather than a guess.
The customer block
POST /sessions requires a customer object on every call. There is no opt-out and no per-merchant
exemption: a create call without it is refused with PAY_CUSTOMER_REQUIRED (422).
"customer": {
"id": "user-1042",
"name": "Hamid Khosravi",
"native_name": "حمید خسروی",
"email": "hamid@example.com"
}| Field | Required | Constraints | What it is |
|---|---|---|---|
id | Yes | [A-Za-z0-9_.:-], 1-128 | Your own internal user id for this customer. The binding is keyed on it forever, so it must be stable in your system |
name | Yes | 1-255 | The customer's full name, in Latin script |
native_name | No, strongly recommended | 1-255 | The full name in the customer's native script. It is the strong basis for the name check, so send it whenever you hold it |
email | Yes | a valid email | The customer's email address with you. It may differ from their Coinland email |
Two rules follow from the block being part of the contract:
- Never send a placeholder. The identity you send is the identity that gets bound. A
"guest"or"test"value binds a real Coinland account to the wrong identity, permanently -- see going live. - The identity is part of the idempotency fingerprint. Replaying the same
reference_idwith a differentcustomerisPAY_DUPLICATE_REFERENCE(409), exactly like changing the amount would be.
The binding ceremony
The first time a given customer.id pays you, the hosted widget runs a binding ceremony before
the payment can proceed:
The payer confirms the link, twice. Two explicit confirmations, each with an "I have read and understood" checkbox: that the Coinland account they are signed into is their own, and that it will be permanently linked to this customer identity at your business. There is no quiet consent -- a payer cannot bind an account without having read what that means.
Coinland checks the name. A fuzzy comparison between the name and native_name you supplied and
the verified (KYC) name on the Coinland account. native_name is the strong basis for this check,
which is why sending it is worth the extra column in your database.
The check decides what happens next.
- Match -- the binding becomes
activeand the payment proceeds. - No match -- the binding becomes
pending_review: the payment is refused, and the binding waits in your review queue. Approving it activates the binding without the customer re-running the ceremony; rejecting it retires the binding.
Completing any payment -- first or hundredth -- also requires the payer's two-factor (TOTP) code, the
same security tier as a withdrawal. That step is entirely widget-side: the widget can surface
TOTP_REQUIRED, TOTP_NOT_ENROLLED or TOTP_INVALID to the payer, and none of them reaches your
integration.
After the binding
Once a binding is active:
- Only the bound Coinland account can pay sessions created for that
customer.id. Any other account is refused withPAY_BINDING_MISMATCH(403). - The customer can never change the binding. There is no customer-side unlink, re-link or transfer. Only you can revoke it.
- One live binding per side. A
customer.idthat already holds a live binding cannot bind a second account, and a Coinland account bound to one of your customers cannot bind to another -- either attempt isPAY_BINDING_CONFLICT(409).
Everything below runs under your checkout key (Authorization: Bearer clpay_live_…), the same key
that creates sessions.
Reading a binding
curl https://my.coinlandexchange.com/api/pay/v1/customers/user-1042/binding \
-H "Authorization: Bearer $COINLAND_PAY_KEY"{
"customer_id": "user-1042",
"state": "active",
"binding": {
"binding_id": "5b8e2f40-7a13-4c96-8d2e-1f6a9c3b70e4",
"customer_id": "user-1042",
"payer_id": "7c1e5b90-3f42-4a86-9d05-2b8e4c1f6a37",
"masked_name": "H**** K****",
"supplied_name": "Hamid Khosravi",
"supplied_name_native": "حمید خسروی",
"supplied_email": "hamid@example.com",
"status": "active",
"match_score": "0.94000",
"match_passed": true,
"bound_at": "2026-08-12T10:15:22.000Z",
"reviewed_at": null,
"created_at": "2026-08-12T10:14:03.000Z"
}
}state is one of three values:
state | Meaning | binding |
|---|---|---|
none | This customer.id has never bound an account | null |
active | Bound; the linked account can pay | the binding object |
pending_review | The name check failed; the customer cannot pay until you resolve it | the binding object |
Two fields deserve a precise reading:
masked_nameis the Coinland account's verified name, masked ("H**** K****"). The API never reveals the account's full name or its Coinland email -- the mask is enough to recognise the person you meant, and not enough to identify a stranger.payer_idis the same opaque handle the payout API uses, so a binding hands you the recipient argument for a payout with no lookup.
supplied_name, supplied_name_native and supplied_email echo back what you sent on the session
that created the binding -- your side of the comparison, next to match_score and match_passed, which
record how the check went.
Reviewing a pending binding
A pending_review binding means a real person tried to pay and could not. It sits in your review
queue in two places: the Customers tab of your business console, and the API below -- resolve it
from whichever fits your operation, the result is the same.
curl -X POST https://my.coinlandexchange.com/api/pay/v1/customers/user-1042/binding/review \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{"action": "approve"}'curl -X POST https://my.coinlandexchange.com/api/pay/v1/customers/user-1042/binding/review \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{"action": "reject", "reason": "Name does not match our records"}'reason is required for reject and recorded against the binding. The response is the updated
binding:
{
"binding": {
"binding_id": "5b8e2f40-7a13-4c96-8d2e-1f6a9c3b70e4",
"customer_id": "user-1042",
"status": "active",
"reviewed_at": "2026-08-12T11:02:47.000Z"
}
}- Approve activates the binding. The customer does not re-run the ceremony -- their consent was already given; what was missing was your confirmation that the name discrepancy is fine. Their next payment attempt goes through.
- Reject retires the binding. The next payment attempt by that customer starts a fresh ceremony, with whatever account they then sign in with.
Reviewing a binding that is not pending_review is PAY_BINDING_STATE (409): the action is only
legal in that one state.
A pending review is a blocked customer, not a background task
While a binding sits in pending_review, that customer cannot pay you at all. Treat the
binding.review webhook as a page to a human, not a row in a report -- the money arrives when
someone decides.
Revoking a binding
Revocation is the statement "this Coinland account no longer belongs to this customer."
curl -X DELETE https://my.coinlandexchange.com/api/pay/v1/customers/user-1042/binding \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "Customer reports the account is no longer theirs"}'{
"customer_id": "user-1042",
"state": "revoked"
}reason is required: severing an identity link is an auditable act, and the record of why belongs
next to the record of what.
There is deliberately no way to name a replacement account in the same call. Revoking severs the link, nothing more; the next time that customer pays, a fresh ceremony runs and the new person proves themselves the same way the first one did -- consent, name check, and review if the check fails. An API that let you bind an arbitrary account directly would let a compromised key redirect a customer's payments, which is exactly what the ceremony exists to prevent.
Webhook events
Three events, delivered and signed exactly like every other event, and carrying the same
caveat: the payload is a hint. Fetch GET /customers/{customer_id}/binding for authoritative state
before acting.
| Type | When | What to do |
|---|---|---|
binding.completed | The customer is bound -- the ceremony passed, or you approved a review | Mark the customer payable on your side |
binding.review | The name check failed; the customer cannot pay until you approve or reject | Alert a human; resolve from the console or the review endpoint |
binding.revoked | The link was severed -- a revoke or a reject | Expect a fresh ceremony on the customer's next payment |
{
"event_id": "f83a2c16-4d95-4b70-8e21-6c0d9f3a5b48",
"type": "binding.review",
"binding_id": "5b8e2f40-7a13-4c96-8d2e-1f6a9c3b70e4",
"customer_id": "user-1042",
"status": "pending_review"
}All three share this shape; status is active on binding.completed, pending_review on
binding.review, and revoked on binding.revoked.
Errors
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
PAY_CUSTOMER_REQUIRED | 422 | POST /sessions was called without the customer block | Send id, name and email on every create call. There is no opt-out |
PAY_BINDING_REQUIRED | 403 | The payer tried to confirm before the ceremony bound them | Widget-side; the payer completes the ceremony first. Nothing to fix in your code |
PAY_BINDING_REVIEW | 403 | The binding is awaiting your review, so the payment is refused | Approve or reject it -- console Customers tab or the review endpoint |
PAY_BINDING_MISMATCH | 403 | The signed-in Coinland account is not the one bound to this customer.id | The customer signs in with the bound account -- or, if the link is genuinely wrong, you revoke it |
PAY_BINDING_CONFLICT | 409 | The customer.id or the Coinland account already holds a live binding | Read the binding and decide; revoke first if the existing link is the wrong one |
PAY_BINDING_STATE | 409 | The action is illegal in the binding's current state, for example reviewing an active binding | Read the binding and branch on status |
The confirm step can also answer TOTP_REQUIRED, TOTP_NOT_ENROLLED or TOTP_INVALID -- the
two-factor step-up every payment carries. Those are surfaced to the payer inside the widget and never
reach your integration.
"My customer changed their Coinland account"
The question every support desk eventually gets. The answer is one move: revoke the binding, with
the reason, and do nothing else. The next time that customer pays, the widget runs a fresh ceremony
against whatever account they sign in with, the name check runs again, and the new binding earns its
active state the same way the old one did.
Do not try to "transfer" a binding by editing the customer record on your side: a new customer.id
for the same person splits their history in your books and leaves the old id's binding live.
Checklist
customersent on every create call, from your customer database, never a placeholder.native_nameincluded whenever you hold it -- it is what the name check leans on.customer.idstable for the lifetime of the customer in your system.binding.reviewalerts a human; someone owns the review queue.- Revocation is your one tool for account changes -- revoke and let the ceremony do the rest.