Escrow with inspection
One buyer · one seller · one milestone · inspection enabled at org level. One extra step compared to Simple — CONFIRM_INSPECTION sits between ACCEPT_CONTRACT and SETTLE_CONTRACT.
Same escrow as the Simple flow — the buyer pays, Waffy holds the funds. With inspection enabled, once the admin confirms delivery via ACCEPT_CONTRACT the contract moves to ITEM_INSPECTION and the buyer reviews in the Waffy app. Once they confirm, the admin calls CONFIRM_INSPECTION, then SETTLE_CONTRACT.
Inspection is an org-level setting configured at onboarding — not a per-milestone flag. If your org has inspection disabled, use the Simple escrow flow instead.
Parties
| Role | API value | What they do | arbitrator | isSender |
|---|---|---|---|---|
| Broker — your org admin | BROKER | Creates and manages the contract | true | true |
| Buyer | CUSTOMER | Pays into escrow | false | — omit |
| Seller | PROVIDER | Receives payout on settlement | — | — omit |
Flow
Walkthrough
Before you start
You need an ORG_TOKEN and a CLIENT_ADMIN_TOKEN, and your org admin's phone number as WAFFY_ADMIN_PHONE — see the Authentication page.
You also need ADMIN_ID — your org admin's own Waffy user ID. Fetch it once and store it:
curl -s "$AUTH_BASE_URL/api/users/me" \ -H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" # save data.id as ADMIN_ID
Set up the buyer
Three calls in order: register, add IBAN, add address. IBAN and address are only required if the buyer may receive a bank transfer refund — card payments (Mada, Visa, Apple Pay) refund directly to the original payment method.
1a — Register
curl -s -X POST "$ID_BASE_URL/realms/waffy/waffy-partner/customers" \
-H "Authorization: Bearer $ORG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "{{BUYER_PHONE}}",
"firstName": "{{BUYER_FIRST_NAME}}",
"lastName": "{{BUYER_LAST_NAME}}"
}'{ "waffyUserId": 99001, "userIdentification": 39311497 }Save the ids, then get consent
Save waffyUserId as BUYER_ID. A newly linked customer stays PENDING until they consent — see Customers & consent. Consent is needed before the payment ticket in the checkout step.
1b — Add IBAN
Required for refund payouts. Saudi format: SA + 22 digits.
curl -s -X POST "$AUTH_BASE_URL/api/users/external/$BUYER_ID/banks" \
-H "Authorization: Bearer $ORG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"iban": "{{BUYER_IBAN}}",
"currency": "SAR",
"beneficiaryName": "{{BUYER_FULL_NAME}}",
"nationalId": "{{BUYER_NATIONAL_ID}}"
}'1c — Add address
Required alongside IBAN for bank transfer cash-out.
curl -s -X POST "$AUTH_BASE_URL/api/external/profiles/$BUYER_ID/addresses" \
-H "Authorization: Bearer $ORG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"addressLabel": "HOME",
"street": "{{BUYER_STREET}}",
"district": "{{BUYER_DISTRICT}}",
"city": "{{BUYER_CITY}}",
"countryCode": "SA",
"postalCode": "{{BUYER_POSTAL_CODE}}"
}'Set up the seller
Same three calls for the seller. IBAN and address are required for the seller — cashout will be blocked until they are on file.
2a — Register
curl -s -X POST "$ID_BASE_URL/realms/waffy/waffy-partner/customers" \
-H "Authorization: Bearer $ORG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "{{SELLER_PHONE}}",
"firstName": "{{SELLER_FIRST_NAME}}",
"lastName": "{{SELLER_LAST_NAME}}"
}'Save waffyUserId as SELLER_ID.
2b — Add IBAN
curl -s -X POST "$AUTH_BASE_URL/api/users/external/$SELLER_ID/banks" \
-H "Authorization: Bearer $ORG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"iban": "{{SELLER_IBAN}}",
"currency": "SAR",
"beneficiaryName": "{{SELLER_FULL_NAME}}",
"nationalId": "{{SELLER_NATIONAL_ID}}"
}'2c — Add address
curl -s -X POST "$AUTH_BASE_URL/api/external/profiles/$SELLER_ID/addresses" \
-H "Authorization: Bearer $ORG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"addressLabel": "HOME",
"street": "{{SELLER_STREET}}",
"district": "{{SELLER_DISTRICT}}",
"city": "{{SELLER_CITY}}",
"countryCode": "SA",
"postalCode": "{{SELLER_POSTAL_CODE}}"
}'Create the complex contract
The parent is a container — it holds no money. itemPrice: 0 is intentional.
curl -s "$API_BASE_URL/api/external/contracts" \
-H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "COMPLEX_CONTRACT",
"senderRole": "BROKER",
"contractClassification": "BASIC",
"currency": "SAR",
"itemPrice": 0,
"itemDetail": {
"title": "Order #1001",
"description": "Product description here"
},
"returnPolicy": "NO_RETURN",
"returnFeePayee": "PROVIDER",
"waffyTermsAccepted": true,
"isParent": true
}'{
"data": {
"id": "507f1f77bcf86cd799439011",
"type": "COMPLEX_CONTRACT",
"senderRole": "BROKER",
"status": "CREATED"
}
}Save waffyUserId as CONTRACT_ID.
Add the milestone
This is where the amount lives. Do not pass paymentMethods, isDeliverable, or isInspectable — these are org-level settings configured at onboarding.
curl -s -X PATCH "$API_BASE_URL/api/external/contracts/$CONTRACT_ID/milestones" \
-H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"milestones": [{
"type": "MILESTONE_CONTRACT",
"senderRole": "BROKER",
"itemDetail": {
"title": "Order #1001",
"description": "Product description here"
},
"itemPrice": 1000,
"currency": "SAR",
"returnPolicy": "NO_RETURN",
"returnFeePayee": "PROVIDER",
"deadLine": "2026-12-31T23:59:59Z",
"waffyTermsAccepted": true
}]
}'{
"data": {
"parentContractId": "507f1f77bcf86cd799439011",
"milestones": [{
"id": "607f1f77bcf86cd799439022",
"itemPrice": 1000,
"status": "CREATED"
}]
}
}Save milestones[0].id as MILESTONE_ID.
Add parties to the milestone
All three parties in one call using phone numbers. Once this lands, the milestone is payable automatically — no separate publish step for API integrations.
curl -s -X PATCH "$API_BASE_URL/api/external/contracts/$CONTRACT_ID/parties" \
-H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"mileStonesParties\": {
\"$MILESTONE_ID\": [
{
\"phoneNumber\": \"$BUYER_PHONE\",
\"role\": \"CUSTOMER\",
\"amount\": 1000,
\"arbitrator\": false
},
{
\"phoneNumber\": \"$WAFFY_ADMIN_PHONE\",
\"role\": \"BROKER\",
\"amount\": 960,
\"arbitrator\": true,
\"isSender\": true
},
{
\"phoneNumber\": \"$SELLER_PHONE\",
\"role\": \"PROVIDER\",
\"amount\": 40
}
]
}
}"Party rules — no exceptions
- •
BROKERmust havearbitrator: trueandisSender: true— the contract creator - •
CUSTOMERandPROVIDER— omitisSenderentirely
What the buyer and seller experience
If your org has invitationAllowed: false (most orgs) — parties are auto-joined immediately after this call. No action required from buyer or seller before payment can proceed.
If invitationAllowed: true — buyer and seller receive an invitation in the Waffy app and must accept before payment becomes available. Your account manager confirms which applies to your org during onboarding.
Generate the buyer's checkout link
Get the checkout URL with the client admin token, then a payment ticket for the buyer right before you redirect them.
Checkout URL. Use the same client you signed in with for the client admin token:
curl -s "$API_BASE_URL/api/external/contracts/startPayment/$MILESTONE_ID/$CLIENT_ID?redirectUrl=https://yourapp.com/done&paymentType=PURCHASE" \ -H "Authorization: Bearer $CLIENT_ADMIN_TOKEN"
{
"data": "https://external.waffyapp.com/external/UXFmUCaG?client_id=...&payment_methods=MADA,VISA,APPLE_PAY"
}payment_methods is auto-populated from your org's contracted methods — you do not set it.
Then, right before redirecting, get a payment ticket for the buyer. PAYMENT_REF is the contract's shortId, the value in the checkout URL path above (here UXFmUCaG). The ticket is valid for 5 minutes.
curl -s -X POST "$ID_BASE_URL/realms/waffy/protocol/openid-connect/token" \
-H "Accept: application/json" \
--data-urlencode "grant_type=urn:waffy:params:oauth:grant-type:customer-token-exchange" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode "login_hint=$BUYER_PHONE" \
--data-urlencode "scope=payment:$PAYMENT_REF"
# => { "ticket": "a1b2c3...", "expiresIn": 300 } — save as TICKETTwo ways to present the checkout URL:
Option A — Raw URL redirect (Flutter and frameworks the SDK doesn't cover)
const url = checkoutUrl + "&ticket=" + encodeURIComponent(TICKET); window.location.href = url; // or Flutter: launchUrl(Uri.parse(url))
Option B — Waffy SDK (recommended for Web, Android, iOS, React Native)
Include the SDK script from https://sdk.waffyapp.com/v2/waffy-payment-display.min.js, then call:
WaffyPaymentDisplay.show({
paymentUrl: "https://external.waffyapp.com/external/UXFmUCaG?client_id=...",
userToken: CUSTOMER_TOKEN,
mode: "modal" // or "popup" or "iframe"
});Android / iOS / React Native use the platform SDK equivalents — your integration team receives package references during onboarding. The SDK example above still shows the legacy customer token; confirm with Waffy whether your SDK version accepts a payment ticket.
Buyer pays — listen for webhook
When the buyer completes payment Waffy fires a webhook to your configured endpoint:
{
"contractId": "607f1f77bcf86cd799439022",
"status": "PAID",
"referenceId": "txn_abc123"
}contractId is the milestone ID.
Always verify the webhook signature
Every request carries a Waffy-Signature header. HMAC-SHA256 the raw body with your webhook secret and compare before acting. Respond HTTP 200 immediately and process async.
Waffy retries failed deliveries 3 times: after 1s, 10s, and 100s. If your endpoint doesn't respond within a few seconds, the delivery is marked failed and retried.
Funds are now held in escrow. The contract moves from PAID to WAITING_FOR_DELIVERY — the seller fulfilment phase begins.
Admin confirms delivery
Once you confirm delivery happened on your side, call ACCEPT_CONTRACT. Use actorRole: CLIENT_ADMIN with the admin's own user ID as userId, the same as for SETTLE.
curl -s "$API_BASE_URL/contract-actions/external" \
-H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"contractId\": \"$MILESTONE_ID\",
\"userId\": \"$ADMIN_ID\",
\"actorRole\": \"CLIENT_ADMIN\",
\"contractAction\": \"ACCEPT_CONTRACT\",
\"contractType\": \"MILESTONE_CONTRACT\"
}"What happens next — inspection window opens
Once delivery is acknowledged the contract moves to ITEM_INSPECTION — the buyer reviews the item in the Waffy app. Funds remain held in escrow throughout. Confirm the buyer's acceptance in Step 9.
With inspection enabled, settlement is blocked until you call CONFIRM_INSPECTION (or REJECT_CONTRACT) in the next step.
Admin confirms inspection
Once the buyer signals acceptance in the Waffy app, call CONFIRM_INSPECTION to mark inspection complete. Same endpoint and body shape as Step 8 — only the action string changes. actorRole: BROKER as before. This is the one call here that the Partner API collection does not cover, so confirm its actorRole with Waffy for your integration.
curl -s "$API_BASE_URL/contract-actions/external" \
-H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"contractId\": \"$MILESTONE_ID\",
\"userId\": \"$ADMIN_ID\",
\"actorRole\": \"BROKER\",
\"contractAction\": \"CONFIRM_INSPECTION\",
\"contractType\": \"MILESTONE_CONTRACT\"
}"Reject path — buyer rejects the item
If the buyer rejects during inspection, call REJECT_CONTRACT instead — same body as accept, with actorRole: CLIENT_ADMIN. The refund flows through Step 10 settlement.
curl -s "$API_BASE_URL/contract-actions/external" \
-H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"contractId\": \"$MILESTONE_ID\",
\"userId\": \"$ADMIN_ID\",
\"actorRole\": \"CLIENT_ADMIN\",
\"contractAction\": \"REJECT_CONTRACT\",
\"contractType\": \"MILESTONE_CONTRACT\"
}"After this call the milestone moves to ready-to-settle. You will receive a CASHOUT_IN_PROGRESS webhook once you trigger settlement in the next step.
Admin settles
Distribute the funds. cashOutAmountList must sum to exactly itemPrice. Use actorRole: CLIENT_ADMIN. Pay the seller and your own org account: ORG_ACCOUNT_ID is your organization's account id (prefilled as org_account_id in the Postman environment from the dashboard), not the admin's user ID.
curl -s "$API_BASE_URL/contract-actions/external" \
-H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"contractId\": \"$MILESTONE_ID\",
\"userId\": $ADMIN_ID,
\"actorRole\": \"CLIENT_ADMIN\",
\"senderId\": $ADMIN_ID,
\"contractType\": \"MILESTONE_CONTRACT\",
\"contractAction\": \"SETTLE_CONTRACT\",
\"cashOutAmountList\": [
{ \"id\": $SELLER_ID, \"amountDue\": $SELLER_AMOUNT },
{ \"id\": $ORG_ACCOUNT_ID, \"amountDue\": $BROKER_AMOUNT }
]
}"Settlement math — must be exact
The fee is calculated after payment based on the payment method used and your org's agreement — not pre-calculated at milestone creation. The total of all amountDue entries must equal itemPrice exactly — any mismatch rejects the call.
Waffy processes bank transfers. You will receive CASHOUT_IN_PROGRESS then COMPLETED webhooks when all parties are paid out.
Common mistakes
| Step | Mistake | Fix |
|---|---|---|
| 1–2 | Skipping IBAN or address for seller | Cashout will be blocked until IBAN + address are on file. For buyer, only needed if they may receive a bank transfer refund. |
| 3 | itemPrice: 0 on milestone | Parent contract is 0. Milestone carries the real amount (1000). |
| 4 | senderRole: PROVIDER on milestone | Must be BROKER — matches the parent contract sender. |
| 4 | paymentMethods / isDeliverable / isInspectable on milestone | Org-level settings — passing per-milestone is rejected. Inspection is enabled for your org at onboarding, not per milestone. |
| 5 | isSender on PROVIDER | Omit entirely — passing it is rejected by the API. |
| 5 | BROKER missing arbitrator: true or isSender: true | Both fields are required on BROKER, no exceptions. |
| 6 | Reusing a payment ticket | A ticket is valid for 5 minutes and one payment — get a fresh one immediately before redirecting to checkout. |
| 6 | Signing in with one client and starting payment with another | Use the same client_id for the client admin token and for startPayment — the contract is attributed to the client you signed in with. |
| 8 | actorRole: BROKER on ACCEPT_CONTRACT | Use CLIENT_ADMIN with the admin’s own user ID, the same as SETTLE_CONTRACT. |
| 9 | Skipping CONFIRM_INSPECTION and going straight to SETTLE_CONTRACT | With inspection enabled, settlement is blocked until CONFIRM_INSPECTION (or REJECT_CONTRACT) is called. |
| 9 | Calling CONFIRM_INSPECTION before the buyer signals acceptance | Wait for the buyer's in-app decision. Confirming early skips the buyer-protection step inspection is designed for. |
| 9 | actorRole: BROKER on REJECT_CONTRACT | REJECT_CONTRACT uses CLIENT_ADMIN, like ACCEPT_CONTRACT and SETTLE_CONTRACT. CONFIRM_INSPECTION is not in the Partner API collection: confirm its actorRole with Waffy. |
| 10 | Paying the admin user in cashOutAmountList | Pay the seller and your org account (org_account_id), not the admin’s user ID. |
| 10 | cashOutAmountList doesn't sum to itemPrice | All entries must sum to itemPrice exactly — over or under is rejected. |
| 10 | Calling SETTLE before CONFIRM_INSPECTION | SETTLE is only valid after CONFIRM_INSPECTION (or REJECT_CONTRACT) moves the milestone to ready-to-settle. |