{
	"info": {
		"name": "Waffy Partner API",
		"description": "Partner-facing collection for integrating with Waffy: authenticate as your organization, register/link customers, establish consent, redirect to checkout, and call the business APIs (contracts, payments, balance). See the accompanying README for the full how-to-call-it guide.",
		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
	},
	"item": [
		{
			"name": "Authentication",
			"item": [
				{
					"name": "Get Org Token (client_credentials)",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"const json = pm.response.json();",
									"pm.collectionVariables.set(\"org_token\", json.access_token);",
									"pm.test(\"got an access_token\", () => pm.expect(json.access_token).to.be.a(\"string\"));"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"auth": {
							"type": "basic",
							"basic": [
								{
									"key": "username",
									"value": "{{client_id}}",
									"type": "string"
								},
								{
									"key": "password",
									"value": "{{client_secret}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Accept",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "urlencoded",
							"urlencoded": [
								{
									"key": "grant_type",
									"value": "client_credentials",
									"type": "text"
								}
							]
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/protocol/openid-connect/token",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"protocol",
								"openid-connect",
								"token"
							]
						},
						"description": "Tier 1 — the org acting for itself, no specific customer involved."
					},
					"response": []
				},
				{
					"name": "Register / Link Customer",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"if (pm.response.code === 201) {",
									"  const json = pm.response.json();",
									"  pm.collectionVariables.set(\"customer_waffy_user_id\", json.waffyUserId);",
									"}"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{org_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"phoneNumber\": \"{{customer_phone}}\",\n    \"email\": \"{{customer_email}}\",\n    \"firstName\": \"{{customer_first_name}}\",\n    \"lastName\": \"{{customer_last_name}}\",\n    \"consentMethod\": \"{{consent_method}}\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-partner/customers",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-partner",
								"customers"
							]
						},
						"description": "Replaces `POST /v2/api/users/sign-up`. Waffy ID resolves-or-creates the customer in-process and syncs the legacy account, then creates a `client_user_link` — `PENDING` unless your client has `auto-accept-consent=true` (a staff-reviewed exception, see admin-app).\n\n**Response (201)**: `{ \"waffyUserId\": 123, \"userIdentification\": 456 }`.\n\n**Only `phoneNumber` is required** — email/firstName/lastName are optional. Only `+9665XXXXXXXX`-shaped Saudi mobile numbers (or numbers matching your org's configured phone rules) are accepted; anything else returns 400.\n\n**`consentMethod` accepts exactly three values — `\"sms\"`, `\"otp\"`, or empty/omitted:**\n- `\"sms\"` chains the signed-link SMS into this same call (one-call convenience, matches the old always-auto-send behavior).\n- `\"otp\"` is accepted but currently a no-op here — it does not chain an OTP send; call `Send OTP (org-driven)` / `Verify OTP (org-driven)` separately.\n- empty/omitted sends nothing further and leaves the customer `PENDING` — **unless your client has `auto-accept-consent=true`** (a staff-reviewed exception, see admin-app), in which case empty is exactly right: the link is created already-consented automatically and no further call is needed."
					},
					"response": []
				},
				{
					"name": "Register / Link Provider",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"if (pm.response.code === 201) {",
									"  const json = pm.response.json();",
									"  pm.collectionVariables.set(\"provider_id\", json.userIdentification);",
									"}"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{org_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"phoneNumber\": \"{{provider_phone}}\",\n    \"email\": \"{{provider_email}}\",\n    \"firstName\": \"{{provider_first_name}}\",\n    \"lastName\": \"{{provider_last_name}}\",\n    \"consentMethod\": \"{{consent_method}}\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-partner/customers",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-partner",
								"customers"
							]
						},
						"description": "**Exactly the same endpoint as `Register / Link Customer` above** — `POST waffy-partner/customers` has no role/type discriminator field at all, so \"Provider\" is purely how this collection uses it: a second, independent set of variables (`provider_phone`/`provider_email`/`provider_first_name`/`provider_last_name`) for registering the other party on a contract (e.g. the `{{provider_id}}` used in `Settle Contract`), not a distinct API, role, or behavior. See `Register / Link Customer`'s own description for the full response shape and `consentMethod` semantics — both apply identically here."
					},
					"response": []
				},
				{
					"name": "Send OTP (org-driven)",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{org_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"phoneNumber\": \"{{customer_phone}}\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-partner/otp/send",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-partner",
								"otp",
								"send"
							]
						},
						"description": "Triggers a Waffy-issued OTP to `phoneNumber`. Nothing is created yet — the customer does not need to be registered first (this call works standalone).\n\n**Response (200)**: `{ \"sent\": true }`.\n\n**429** → your org's hourly `otp/send` rate limit is exceeded — a per-org ceiling; ask Waffy staff to raise yours if you're hitting it under legitimate traffic.\n\n**400** → `phoneNumber` missing or not a recognizable number.\n\n**502** → the OTP dispatch itself failed — transient, retry.\n\nYour own UI then collects the code from the customer and passes it to **Verify OTP (org-driven)**."
					},
					"response": []
				},
				{
					"name": "Verify OTP (org-driven)",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"if (pm.response.code === 200) {",
									"  const json = pm.response.json();",
									"  pm.collectionVariables.set(\"customer_waffy_user_id\", json.waffyUserId);",
									"}"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{org_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"phoneNumber\": \"{{customer_phone}}\",\n    \"otp\": \"{{customer_otp}}\",\n    \"firstName\": \"{{customer_first_name}}\",\n    \"lastName\": \"{{customer_last_name}}\",\n    \"email\": \"{{customer_email}}\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-partner/otp/verify",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-partner",
								"otp",
								"verify"
							]
						},
						"description": "Validates the OTP your own UI collected, resolves-or-creates the customer (same logic as `Register / Link Customer` — no separate registration call needed for this path), and establishes consent in the same call — treated as equally strong proof as a signed-link tap or a live checkout login.\n\n**Response (200)**: `{ \"waffyUserId\": 123, \"userIdentification\": 456 }` — **identifiers only, never a token or ticket.** Once verified, get a payment ticket exactly like any other already-consented customer, via `Get Payment Ticket` above — same call, same shape, no special case for this path.\n\n**400** (plain `{\"error\":\"...\"}`) → wrong/expired OTP, missing `phoneNumber`/`otp`, or an unrecognizable phone number. No side effects on failure.\n\n**502** → a downstream failure creating the customer or recording consent — retry.\n\nA prior `REJECTED` decision (e.g. from an earlier signed-link decline) is reversed by a successful call here, same as a fresh signed-link approval or live checkout login."
					},
					"response": []
				},
				{
					"name": "Send Consent Link (resend)",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{org_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"phoneNumber\": \"{{customer_phone}}\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-partner/consent/send",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-partner",
								"consent",
								"send"
							]
						},
						"description": "**The actual standalone \"send consent\" API** — a separate request, not just the `\"consentMethod\": \"sms\"` flag on `Register / Link Customer` (which only fires on that same call and is a no-op if the customer is already linked). Use this whenever you need to (re)send the signed-link SMS on demand: the customer missed the original message, or you want to give a customer who previously rejected a fresh chance to reconsider.\n\nWorks regardless of the link's current status — `PENDING` (resend) or `REJECTED` (a fresh chance) both work identically; there's no separate \"unreject\" step. The customer must already be linked to your org (i.e. `Register / Link Customer` has been called for this phone number at least once) — this never creates a new link itself.\n\nRate-limited per org on its own counter, separate from `otp/send`'s budget — ask Waffy staff to raise your org's limit (admin-app API Clients tab) via the `consent-send-hourly-limit` client attribute if you're hitting it under legitimate traffic."
					},
					"response": []
				},
				{
					"name": "Get Payment Ticket",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"if (pm.response.code === 200) {",
									"  pm.collectionVariables.set(\"payment_ticket\", pm.response.json().ticket);",
									"}"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"auth": {
							"type": "basic",
							"basic": [
								{
									"key": "username",
									"value": "{{client_id}}",
									"type": "string"
								},
								{
									"key": "password",
									"value": "{{client_secret}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Accept",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "urlencoded",
							"urlencoded": [
								{
									"key": "grant_type",
									"value": "urn:waffy:params:oauth:grant-type:customer-token-exchange",
									"type": "text"
								},
								{
									"key": "login_hint",
									"value": "{{customer_phone}}",
									"type": "text"
								},
								{
									"key": "scope",
									"value": "payment:{{payment_ref}}",
									"type": "text"
								}
							]
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/protocol/openid-connect/token",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"protocol",
								"openid-connect",
								"token"
							]
						},
						"description": "For redirecting an already-linked customer straight to checkout without a fresh live login. Response is **not** a bearer token: `{ \"ticket\": \"a1b2c3...\", \"expiresIn\": 300 }`.\n\nAppend it to the checkout redirect as its own dedicated **`ticket`** query param: `{{checkout_base_url}}/external/{ref}?...&ticket={{payment_ticket}}`. Waffy's checkout page exchanges it for a real token automatically — you don't call anything else for that step.\n\nSucceeds (200, ticket issued) even while consent is still `PENDING` or already `VERIFIED` — checkout re-checks live and falls back to a live login if not yet approved.\n\n**400 `{\"error\":\"consent_rejected\", ...}`** — the customer already declined this org. Terminal for issuing a ticket from this call — but a fresh signed-link tap, `Verify OTP (org-driven)`, or live checkout login can still reverse the underlying decision, after which a retry here succeeds normally.\n\n**400 `{\"error\":\"invalid_grant\", ...}`** — the customer has no link to this client at all; run `Register / Link Customer` first.\n\n**`{{payment_ref}}` must be the contract's `shortId`** — the same short id that's already in the checkout URL path (`.../external/6VzpzZlR?...` → `6VzpzZlR` is the `shortId`), not the contract's internal numeric/UUID id and not an arbitrary order reference of your own. A mismatched `payment_ref` fails downstream business/contract-action calls."
					},
					"response": []
				},
				{
					"name": "Get Client Admin Token (client admin ROPC)",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"const json = pm.response.json();",
									"pm.collectionVariables.set(\"client_admin_token\", json.access_token);",
									"pm.test(\"got an access_token\", () => pm.expect(json.access_token).to.be.a(\"string\"));"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Accept",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "urlencoded",
							"urlencoded": [
								{
									"key": "grant_type",
									"value": "password",
									"type": "text"
								},
								{
									"key": "client_id",
									"value": "{{client_id}}",
									"type": "text"
								},
								{
									"key": "client_secret",
									"value": "{{client_secret}}",
									"type": "text"
								},
								{
									"key": "username",
									"value": "{{org_admin_username}}",
									"type": "text"
								},
								{
									"key": "password",
									"value": "{{org_admin_password}}",
									"type": "text"
								}
							]
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/protocol/openid-connect/token",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"protocol",
								"openid-connect",
								"token"
							]
						},
						"description": "`grant_type=password` (Direct Access Grants / ROPC), against the org's own client — same `client_id`/`client_secret` as `Get Org Token`, plus the org's own **human `CLIENT_ADMIN`**'s login credentials (the same ones used to log into portal-dev.waffyapp.com). Enabled by default on every org client (`directAccessGrantsEnabled: true`, both at self-service/admin-app creation and org-client migration) specifically so this token is obtainable via a pure API call, no browser step.\n\nUsed for every business action that needs a real `CLIENT_ADMIN` authority: `create contract`, `create milestone`, `add parties`, `get complex contract details`, `get contract milestones`, `accept contract`, `reject contract`, `settle contract`, `create withdrawal`, `Balance`, `Balance History`. Sets `{{client_admin_token}}` automatically via this request's test script.\n\n**401/400 `invalid_grant`** — wrong username/password, or the account is disabled/locked.\n\n**400 `unauthorized_client`** — `directAccessGrantsEnabled` isn't actually `true` on this client (e.g. an org migrated before this default shipped, and not yet flipped individually).\n\n**400 `invalid_grant` (\"Client does not belong to this user's organization\").** `client_id` must belong to the same org as `org_admin_username`. If your org has more than one client, any of your own org's clients works; a sibling org's client is rejected outright (previously succeeded with no check at all). New contracts are now attributed to whichever client authenticated *this* call, not a fixed value from account creation — use this same `client_id` for `start payment url`'s `{client_id}` path segment afterward, in the same session."
					},
					"response": []
				}
			]
		},
		{
			"name": "Consent",
			"item": [
				{
					"name": "View Consent Page (reference only)",
					"request": {
						"method": "GET",
						"header": [
							{
								"key": "Accept",
								"value": "text/html"
							}
						],
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-consent/{{consent_token}}",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-consent",
								"{{consent_token}}"
							]
						},
						"description": "The bilingual (ar/en), Waffy ID-hosted page a customer's SMS/WhatsApp link opens — no login required (possession of the link is the trust model, same as OTP). Useful to sanity-check a token actually resolves before asking a real customer to tap it.\n\nAlways returns **200 text/html**, never a 4xx — an invalid, expired, or already-used token renders the same \"Link no longer valid\" page instead of erroring, so check the response body, not the status code."
					},
					"response": []
				},
				{
					"name": "Approve Consent (reference only)",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Accept",
								"value": "text/html"
							}
						],
						"body": {
							"mode": "urlencoded",
							"urlencoded": [
								{
									"key": "approved",
									"value": "true",
									"type": "text"
								}
							]
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-consent/{{consent_token}}/decision",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-consent",
								"{{consent_token}}",
								"decision"
							]
						},
						"description": "Simulates the customer tapping **Approve** on the consent page — same effect as production, so only run this against test customers.\n\nAlways returns **200 text/html** (an \"Approved\" or \"Link no longer valid\" page); the link itself is single-use, so a second call with the same `{{consent_token}}` — approve or reject — renders the invalid-link page.\n\nAfter this, `Get Payment Ticket` above should succeed for this customer instead of returning `consent_required`."
					},
					"response": []
				},
				{
					"name": "Reject Consent (reference only)",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Accept",
								"value": "text/html"
							}
						],
						"body": {
							"mode": "urlencoded",
							"urlencoded": [
								{
									"key": "approved",
									"value": "false",
									"type": "text"
								}
							]
						},
						"url": {
							"raw": "{{id_base_url}}/realms/waffy/waffy-consent/{{consent_token}}/decision",
							"host": [
								"{{id_base_url}}"
							],
							"path": [
								"realms",
								"waffy",
								"waffy-consent",
								"{{consent_token}}",
								"decision"
							]
						},
						"description": "Simulates the customer tapping **Reject** — moves the link to `REJECTED`. After this, `Get Payment Ticket` for this same customer+client returns `400 {\"error\":\"consent_rejected\"}` instead of the retriable `consent_required`, until reversed.\n\n**Not permanent** — `REJECTED` is terminal only for the ticket-issuance call itself: a fresh, live, *interactive* consent event reverses it, since it re-proves the same live presence that established consent the first time. Any of these reverse a prior rejection: tapping a fresh signed link (a new `consent_token` — this one is single-use and won't work twice), a fresh `Verify OTP (org-driven)`, or a live checkout login.\n\nUseful for exercising your own integration's handling of the rejection case, and its recovery, without waiting on a real customer to decline and reconsider."
					},
					"response": []
				}
			],
			"description": "No API calls needed for a real integration — every newly-registered customer (unless your client has `auto-accept-consent=true`, a staff-reviewed exception, or you chained `consentMethod: \"sms\"` into registration) gets a one-time SMS/WhatsApp message with a link to approve or reject your org acting on their behalf. `Get Payment Ticket` above will return `consent_required` until they tap it and approve — or `consent_rejected` (terminal for that specific call — see its own description for the reversal path) if they tap it and decline. The link is valid for ~7 days and single-use.\n\nThe three requests below hit that same signed link directly — useful for testing/debugging without a real phone, but note there's no API to *retrieve* a customer's `consent_token`: on dev, pull it from the SMS/WhatsApp notification log for that test customer and set it as `{{consent_token}}` (the full link is `{{id_base_url}}/realms/waffy/waffy-consent/{{consent_token}}`).\n\n**Two other ways to establish consent** exist alongside this signed link — see the `Authentication` folder's `Send OTP (org-driven)` / `Verify OTP (org-driven)`, and the README for a live checkout login."
		},
		{
			"name": "Complex Contracts",
			"item": [
				{
					"name": "create contract",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"if (pm.response.code === 200 || pm.response.code === 201) {",
									"  pm.collectionVariables.set(\"complex_contract_id\", pm.response.json().data.id);",
									"}"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"type\": \"COMPLEX_CONTRACT\",\n    \"senderRole\": \"PROVIDER\",\n    \"itemDetail\": {\n        \"title\": \"test contract\",\n        \"description\": \"test description\",\n        \"images\": []\n    },\n    \"returnPolicy\": \"NO_RETURN\",\n    \"returnFeePayee\": \"PROVIDER\",\n    \"waffyTermsAccepted\": true,\n    \"category\": \"Services\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{api_base_url}}/api/external/contracts",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"api",
								"external",
								"contracts"
							]
						},
						"description": "Requires a `CLIENT_ADMIN`-authority token, no fallback. Bearer is `{{client_admin_token}}` from `Get Client Admin Token (client admin ROPC)` above."
					},
					"response": []
				},
				{
					"name": "create milestone",
					"event": [
						{
							"listen": "test",
							"script": {
								"exec": [
									"if (pm.response.code === 200) {",
									"  pm.collectionVariables.set(\"milestone_id\", pm.response.json().data.milestones[0].id);",
									"}"
								],
								"type": "text/javascript"
							}
						}
					],
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "PATCH",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"milestones\": [\n        {\n            \"type\": \"MILESTONE_CONTRACT\",\n            \"senderRole\": \"PROVIDER\",\n            \"itemDetail\": {\n                \"title\": \"دفعة اولى\",\n                \"description\": \"\"\n            },\n            \"itemPrice\": 100,\n            \"currency\": \"SAR\",\n            \"returnPolicy\": \"NO_RETURN\",\n            \"returnFeePayee\": \"PROVIDER\",\n            \"deadLine\": \"2027-01-01T00:00:00.000Z\",\n            \"waffyTermsAccepted\": true\n        }\n    ]\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{api_base_url}}/api/external/contracts/{{complex_contract_id}}/milestones",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"api",
								"external",
								"contracts",
								"{{complex_contract_id}}",
								"milestones"
							]
						},
						"description": "PATCH `/{id}/milestones`. Requires a `CLIENT_ADMIN`-authority token, no fallback. Bearer is `{{client_admin_token}}` from `Get Client Admin Token (client admin ROPC)` above."
					},
					"response": []
				},
				{
					"name": "add parties",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "PATCH",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"mileStonesParties\": {\n        \"{{milestone_id}}\": [\n            {\n                \"phoneNumber\": \"{{customer_phone}}\",\n                \"role\": \"CUSTOMER\",\n                \"amount\": 100\n            },\n            {\n                \"userId\": \"{{org_account_id}}\",\n                \"role\": \"BROKER\",\n                \"amount\": 1,\n                \"arbitrator\": true,\n                \"isSender\": true\n            },\n            {\n                \"phoneNumber\": \"{{provider_phone}}\",\n                \"role\": \"PROVIDER\",\n                \"amount\": 99\n            }\n        ]\n    }\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{api_base_url}}/api/external/contracts/{{complex_contract_id}}/parties",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"api",
								"external",
								"contracts",
								"{{complex_contract_id}}",
								"parties"
							]
						},
						"description": "PATCH `/{id}/parties`. Requires a `CLIENT_ADMIN`-authority token, no fallback. Bearer is `{{client_admin_token}}` from `Get Client Admin Token (client admin ROPC)` above."
					},
					"response": []
				},
				{
					"name": "get complex contract details",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "GET",
						"header": [],
						"url": {
							"raw": "{{api_base_url}}/api/contracts/{{complex_contract_id}}",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"api",
								"contracts",
								"{{complex_contract_id}}"
							]
						}
					},
					"response": []
				},
				{
					"name": "get contract milestones",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "GET",
						"header": [],
						"url": {
							"raw": "{{api_base_url}}/api/contracts?parentId={{complex_contract_id}}&sort=createdAt,ASC",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"api",
								"contracts"
							],
							"query": [
								{
									"key": "parentId",
									"value": "{{complex_contract_id}}"
								},
								{
									"key": "sort",
									"value": "createdAt,ASC"
								}
							]
						}
					},
					"response": []
				},
				{
					"name": "accept contract",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"contractId\": \"{{milestone_id}}\",\n    \"userId\": \"{{admin_id}}\",\n    \"actorRole\": \"CLIENT_ADMIN\",\n    \"contractAction\": \"ACCEPT_CONTRACT\",\n    \"contractType\": \"MILESTONE_CONTRACT\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{api_base_url}}/contract-actions/external",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"contract-actions",
								"external"
							]
						},
						"description": "`userId` must be the **client admin's own user identification** (`{{admin_id}}`), with `actorRole: \"CLIENT_ADMIN\"` — not `BROKER`, despite the client admin also being the broker-resolvable identity for the contract elsewhere in this collection."
					},
					"response": []
				},
				{
					"name": "reject contract",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"contractId\": \"{{milestone_id}}\",\n    \"userId\": \"{{admin_id}}\",\n    \"actorRole\": \"CLIENT_ADMIN\",\n    \"contractAction\": \"REJECT_CONTRACT\",\n    \"contractType\": \"MILESTONE_CONTRACT\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{api_base_url}}/contract-actions/external",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"contract-actions",
								"external"
							]
						},
						"description": "`userId` must be the **client admin's own user identification** (`{{admin_id}}`), with `actorRole: \"CLIENT_ADMIN\"` — not `BROKER`, despite the client admin also being the broker-resolvable identity for the contract elsewhere in this collection."
					},
					"response": []
				},
				{
					"name": "settle contract",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"contractId\": \"{{milestone_id}}\",\n    \"userId\": {{admin_id}},\n    \"actorRole\": \"CLIENT_ADMIN\",\n    \"senderId\": {{admin_id}},\n    \"contractType\": \"MILESTONE_CONTRACT\",\n    \"contractAction\": \"SETTLE_CONTRACT\",\n    \"cashOutAmountList\": [\n        { \"id\": {{provider_id}}, \"amountDue\": 90 },\n        { \"id\": {{org_account_id}}, \"amountDue\": 10 }\n    ]\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{api_base_url}}/contract-actions/external",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"contract-actions",
								"external"
							]
						},
						"description": "No `receiverId` field — that was wrong, remove it. Field meanings:\n- `userId` / `senderId` — the **client admin's own user identification** (`{{admin_id}}`), with `actorRole: \"CLIENT_ADMIN\"`.\n- `cashOutAmountList[].id` — one entry per cash-out recipient. `{{provider_id}}` is the **provider's own user identification**. `{{org_account_id}}` is the **org's account identifier** — the same id that already appears in `contract.parties` — not the client admin's user identification; using `{{admin_id}}` here instead was the earlier, incorrect version of this body.\n- `amountDue` values above (90/10) are illustrative — they must sum to the milestone's total payable amount."
					},
					"response": []
				}
			]
		},
		{
			"name": "payment",
			"item": [
				{
					"name": "start payment url",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "GET",
						"header": [
							{
								"key": "Accept",
								"value": "application/json"
							}
						],
						"url": {
							"raw": "{{api_base_url}}/api/external/contracts/startPayment/{{milestone_id}}/{{client_id}}?redirectUrl={{redirect_url}}&paymentType=PURCHASE",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"api",
								"external",
								"contracts",
								"startPayment",
								"{{milestone_id}}",
								"{{client_id}}"
							],
							"query": [
								{
									"key": "redirectUrl",
									"value": "{{redirect_url}}"
								},
								{
									"key": "paymentType",
									"value": "PURCHASE"
								}
							]
						},
						"description": "Returns the checkout page's base URL for this contract/milestone, **without** `userTokenUrl` or `ticket` — your own backend appends one of those.\n\n```\nGET .../startPayment/{{milestone_id}}/{{client_id}}?redirectUrl={{redirect_url}}&paymentType=PURCHASE\n=> {{checkout_base_url}}/external/6VzpzZlR?client_id={{client_id}}&redirect_url={{redirect_url}}&paymentType=PURCHASE&payment_methods=MANUAL_BANK_TRANSFER,MOYASAR,TABBY,APPLE_PAY\n```\n\nRedirect the customer's browser to that URL plus **`&ticket=...`** (from `Get Payment Ticket` above) — a dedicated query param your backend appends, never sent to this endpoint itself.\n\nBearer must be `{{client_admin_token}}` — `{{org_token}}` is not accepted here.\n\nKnown quirk: a nonexistent milestone id currently returns a bare `500` instead of `404` — not something to work around, just don't mistake it for a bug in your own request."
					},
					"response": []
				},
				{
					"name": "create withdrawal",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"beneficiaryId\": {{admin_id}},\n    \"amount\": \"1.00\",\n    \"beneficiaryName\": \"Test User\",\n    \"organizationCode\": \"{{organization_code}}\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{api_base_url}}/payments/withdrawal/",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"payments",
								"withdrawal",
								""
							]
						}
					},
					"response": []
				}
			]
		},
		{
			"name": "Balance",
			"item": [
				{
					"name": "Balance",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "GET",
						"header": [],
						"url": {
							"raw": "{{api_base_url}}/payments/wallets/user/{{customer_waffy_user_id}}",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"payments",
								"wallets",
								"user",
								"{{customer_waffy_user_id}}"
							]
						}
					},
					"response": []
				},
				{
					"name": "Balance History",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{client_admin_token}}",
									"type": "string"
								}
							]
						},
						"method": "GET",
						"header": [],
						"url": {
							"raw": "{{api_base_url}}/payments/wallets/user/{{customer_waffy_user_id}}/history?size=10",
							"host": [
								"{{api_base_url}}"
							],
							"path": [
								"payments",
								"wallets",
								"user",
								"{{customer_waffy_user_id}}",
								"history"
							],
							"query": [
								{
									"key": "size",
									"value": "10"
								}
							]
						}
					},
					"response": []
				}
			]
		},
		{
			"name": "Profile",
			"item": [
				{
					"name": "Update Bank Account (org-attributed)",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{org_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"iban\": \"{{iban}}\",\n    \"currency\": \"SAR\",\n    \"beneficiaryName\": \"{{customer_first_name}} {{customer_last_name}}\",\n    \"nationalId\": \"{{national_id}}\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{auth_base_url}}/api/users/external/{{customer_waffy_user_id}}/banks",
							"host": [
								"{{auth_base_url}}"
							],
							"path": [
								"api",
								"users",
								"external",
								"{{customer_waffy_user_id}}",
								"banks"
							]
						},
						"description": "Adds/updates a bank account on a named, already-registered customer, acting as your organization (not the customer). Bearer: `{{org_token}}` — no consent-gated token needed for this call.\n\n`{{customer_waffy_user_id}}` is set automatically by `Register / Link Customer` or `Verify OTP (org-driven)`."
					},
					"response": []
				},
				{
					"name": "Add User Address (org-attributed)",
					"request": {
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "{{org_token}}",
									"type": "string"
								}
							]
						},
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n    \"addressLabel\": \"HOME\",\n    \"street\": \"{{address_street}}\",\n    \"district\": \"{{address_district}}\",\n    \"postalCode\": \"{{address_postal_code}}\",\n    \"city\": \"{{address_city}}\",\n    \"countryCode\": \"SA\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "{{auth_base_url}}/api/external/profiles/{{customer_waffy_user_id}}/addresses",
							"host": [
								"{{auth_base_url}}"
							],
							"path": [
								"api",
								"external",
								"profiles",
								"{{customer_waffy_user_id}}",
								"addresses"
							]
						},
						"description": "Adds an address on a named, already-registered customer, acting as your organization. Bearer: `{{org_token}}` — no consent-gated token needed for this call.\n\n`addressLabel` accepts `HOME`/`WORK`/etc.; only `addressLabel` and `countryCode` are required, the rest are optional.\n\n`{{customer_waffy_user_id}}` is set automatically by `Register / Link Customer` or `Verify OTP (org-driven)`."
					},
					"response": []
				}
			],
			"description": "Org-attributed profile actions on an already-registered customer — acting as your organization, not the customer. Both use `{{org_token}}` directly; no consent-gated token needed, matching `Register / Link Customer`'s own auth model."
		}
	],
	"variable": [
		{
			"key": "client_admin_token",
			"value": ""
		},
		{
			"key": "checkout_base_url",
			"value": "https://external.waffyapp.com"
		},
		{
			"key": "customer_phone",
			"value": "+9665XXXXXXXX"
		},
		{
			"key": "consent_method",
			"value": ""
		},
		{
			"key": "customer_email",
			"value": "customer@example.com"
		},
		{
			"key": "customer_first_name",
			"value": "Sara"
		},
		{
			"key": "customer_last_name",
			"value": "Al-Otaibi"
		},
		{
			"key": "payment_ref",
			"value": ""
		},
		{
			"key": "customer_otp",
			"value": ""
		},
		{
			"key": "org_token",
			"value": ""
		},
		{
			"key": "customer_token",
			"value": ""
		},
		{
			"key": "payment_ticket",
			"value": ""
		},
		{
			"key": "customer_waffy_user_id",
			"value": ""
		},
		{
			"key": "consent_token",
			"value": ""
		},
		{
			"key": "complex_contract_id",
			"value": ""
		},
		{
			"key": "milestone_id",
			"value": ""
		},
		{
			"key": "admin_id",
			"value": ""
		},
		{
			"key": "provider_id",
			"value": ""
		},
		{
			"key": "provider_phone",
			"value": ""
		},
		{
			"key": "provider_email",
			"value": ""
		},
		{
			"key": "provider_first_name",
			"value": ""
		},
		{
			"key": "provider_last_name",
			"value": ""
		},
		{
			"key": "iban",
			"value": "SA0000000000000000000000"
		},
		{
			"key": "national_id",
			"value": "1000000000"
		},
		{
			"key": "address_street",
			"value": ""
		},
		{
			"key": "address_district",
			"value": ""
		},
		{
			"key": "address_postal_code",
			"value": ""
		},
		{
			"key": "address_city",
			"value": ""
		},
		{
			"key": "redirect_url",
			"value": ""
		}
	]
}
