Build a Payment · Step 4 of 5

Take payment

Get the hosted-checkout URL with the client admin token, get a payment ticket for the buyer, redirect them with the ticket, then wait for the PAID webhook.

  1. 1Auth
  2. 2Users
  3. 3Contract
  4. 4Payment
  5. 5Settle

1. Start payment

Use the client admin token. The org token is not accepted here. This returns the hosted checkout URL for the milestone. Use the same client you signed in with when you created the contract.

bash
curl -s -G "$API_BASE_URL/api/external/contracts/startPayment/$MILESTONE_ID/$CLIENT_ID" \
  -H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \
  --data-urlencode "redirectUrl=https://yourapp.com/done" \
  --data-urlencode "paymentType=PURCHASE"

Response:

json
{
  "data": "https://external.waffyapp.com/external/UXFmUCaG?client_id=...&payment_methods=MANUAL_BANK_TRANSFER,MOYASAR,TABBY,APPLE_PAY"
}
  • The host is your environment's checkout host (for example external-dev.waffyapp.com in the sandbox).
  • payment_methods is filled in from your organization's contracted methods. You do not set it.
  • The path segment after /external/ (here UXFmUCaG) is the contract's shortId. You need it for the ticket.
  • A milestone id that does not exist currently returns a bare 500, not 404.

2. Get a payment ticket

Right before the redirect, ask for a ticket for this buyer and this payment. The buyer must have given consent (see Customers & consent).

bash
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"
json
{ "ticket": "a1b2c3...", "expiresIn": 300 }

PAYMENT_REF is the contract's shortId, the value in the checkout URL path from step 1. Not an internal id or an order reference of your own. A mismatch makes later calls fail. More about tickets in Authentication.

ResponseMeaning
200 ticketIssued, even while consent is still pending. Checkout checks it again live.
400 consent_rejectedThe buyer declined your organization. A fresh consent link, OTP verification or checkout login can reverse it, then retry.
400 invalid_grantThe buyer is not linked to this client. Register or link them first.

3. Send the buyer to checkout

Two ways to present the checkout URL. Pick one based on your front-end stack.

Option A: raw URL redirect (Flutter and frameworks the SDK doesn't cover)

Append the ticket as its own ticket query parameter to the URL from step 1 and redirect the buyer's browser there. Do this from your backend. Waffy's checkout page exchanges the ticket for a real token itself, so your integration ends at the redirect.

javascript
const url = checkoutUrl + "&ticket=" + encodeURIComponent(ticket);
window.location.href = url;  // or Flutter: launchUrl(Uri.parse(url))

Option B: Waffy SDK (Web, Android, iOS, React Native)

The SDK shows the checkout in a modal, popup or iframe. Include the script from https://sdk.waffyapp.com/v2/waffy-payment-display.min.js in your page, then call:

javascript
WaffyPaymentDisplay.show({
  paymentUrl: "https://external.waffyapp.com/external/UXFmUCaG?client_id=...",
  userToken: CUSTOMER_TOKEN,
  mode: "modal"  // or "popup" or "iframe"
});

Check the SDK token with Waffy

The SDK example above is documented with the legacy customer token. Ask Waffy whether your SDK version accepts a payment ticket before using it with the new flow.

Android, iOS and React Native use the platform equivalents of the same SDK. Your integration team receives the package references during onboarding.

Legacy: userTokenUrl (deprecated)

Organizations on the legacy authentication append a userTokenUrl parameter holding the buyer's legacy customer_token instead of a ticket. That keeps working while they move, but new integrations should use the ticket.

Handle the PAID event

When the buyer completes payment, you will receive:

json
{
  "contractId": "607f1f77bcf86cd799439022",
  "status": "PAID",
  "referenceId": "txn_abc123"
}

contractId is the milestone ID. Always verify the Waffy-Signature header before processing.

Webhook URL is configured by Waffy at onboarding

You don't PATCH it yourself. Give your endpoint URL to your account manager during sandbox set-up and again before go-live. If it ever needs to change, email developers@waffyapp.com.

Verify the webhook signature

Every webhook request carries a Waffy-Signature header. Compute HMAC-SHA256 of the raw request body using your webhook secret and compare. Reject any request that does not match.

bash
# Header format
Waffy-Signature: sha256=abc123def456...

# Verification (Node.js example)
const expected = "sha256=" + crypto
  .createHmac("sha256", WEBHOOK_SECRET)
  .update(rawBody)
  .digest("hex");

if (expected !== req.headers["waffy-signature"]) {
  return res.status(401).send("Signature mismatch");
}

Retry policy

Waffy retries failed webhook deliveries 3 times: after 1 s, 10 s, and 100 s. Your endpoint must respond HTTP 200 immediately and process the event asynchronously.

Respond 200 first, process second

If your endpoint takes more than a few seconds to respond, Waffy will mark the delivery as failed and retry. Acknowledge immediately with HTTP 200, then enqueue the event for async processing.

Webhook status values

StatusMeaning
PAIDBuyer paid. Funds are in escrow. Move to delivery.
CASHOUT_IN_PROGRESSFunds are being released to parties. No action needed.
COMPLETEDContract is fully settled. All parties have been paid out.