Webhook Payload Reference
Every WePay webhook uses the same envelope. What changes is which properties appear inside data, and that depends on the event and on whether the contract uses milestones.
This page focuses on which properties you get, and what they mean. Examples are taken from captured webhook deliveries. reference and metadata are included only when they were supplied at contract creation, so they are absent from these captures.
The envelope
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event": "payment.completed",
"createdAt": "2026-09-22T10:15:00Z",
"data": { }
}
| Property | Description |
|---|---|
id | Unique ID for this delivery. Use it for idempotency |
event | The event type. Also sent in the X-WePay-Event header |
createdAt | When the webhook was created |
data | The event body, described below |
Rule 1 — Missing properties are absent, not null
WePay omits properties that have no value. A contract without milestones does not send "milestoneId": null — the key is simply not there.
// Contract without milestones — no milestoneId key at all
"data": {
"contractId": "CNT-01K5ZR8M2N4Q",
"status": "Escrow"
}
Always check for presence, not for null.
if ("milestoneId" in event.data) { /* milestone-scoped event */ }
New properties may be added to data over time. Read the properties you need and ignore the rest. Do not fail on unknown properties.
Rule 2 — status means different things
This is the most important thing to understand.
| Contract type | data.status refers to | data.contractStatus |
|---|---|---|
| Without milestones | the contract | absent |
| With milestones | the milestone | present, holds the contract status |
dispute.created and dispute.resolved are the exception: they omit both status and contractStatus. Use milestoneId only to tell which milestone the dispute belongs to.
On a milestone contract, data.status is the status of that one milestone, not the whole contract.
Side by side
Same event, two contract types.
Contract without milestones — funded (payment.completed)
"data": {
"contractId": "CNT-01M48FPS2A4R",
"status": "Escrow",
"previousStatus": "Approved",
"amount": 3069,
"currency": "SAR"
}
status is the contract. The contract is funded.
Contract with milestones — one milestone funded (milestone.payment.completed)
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"status": "Escrow",
"previousStatus": "Approved",
"contractStatus": "Escrow",
"contractPreviousStatus": "Approved",
"amount": 634.5
"currency": "SAR"
}
Here status: Escrow describes only that milestone. previousStatus is the milestone's previous status (Pending). The contract itself stays Approved in contractStatus while other milestones remain unpaid.
If you read data.status as the contract status, a milestone contract will look fully paid as soon as the first milestone is funded.
- When
contractStatusis present, it is the contract status andstatusis the milestone - When
contractStatusis absent,statusis the contract status dispute.createdanddispute.resolvedhave neither, even whenmilestoneIdis present
const contractStatus = "contractStatus" in event.data
? event.data.contractStatus
: event.data.status;
Rule 3 — Event names switch too
For anything that can happen at either level, the event name itself tells you the scope.
| Contract-scoped | Milestone-scoped |
|---|---|
payment.completed | milestone.payment.completed |
contract.released | milestone.released |
contract.disputed | milestone.disputed |
contract.disput.resolved | milestone.disput.resolved |
contract.refunded | milestone.refunded |
contract.completed | milestone.completed |
contract.payment.underpaid | milestone.payment.underpaid |
contract.payment.overpaid | milestone.payment.overpaid |
refund.partial-initiated | milestone.refund.partial-initiated |
refund.partial-succeeded | milestone.refund.partial-succeeded |
refund.full-initiated | milestone.refund.full-initiated |
refund.full-succeeded | milestone.refund.full-succeeded |
Events that are always contract-scoped: contract.created, contract.approved, contract.rejected, contract.cancelled.
dispute.created and dispute.resolved keep the same event name for both scopes. When the dispute is on a milestone, data.milestoneId is present. These two events do not include status, previousStatus, amount, or contractStatus.
The resolved status events are named contract.disput.resolved and milestone.disput.resolved.
Rule 4 — Identifiers are opaque
Treat contractId, milestoneId, disputeId, paymentId, transactionId, and transactionReference as opaque strings. Do not parse them, infer ordering from them, or assume a length or format. Store them as text. disputeId is a numeric string such as "774", not a prefixed id.
Payloads by event family
Contract lifecycle
contract.created · contract.approved · contract.rejected · contract.cancelled · contract.delivered · contract.received · contract.completed
"data": {
"contractId": "CNT-01M48D4SZYAW",
"status": "Approved",
"previousStatus": "Pending",
"amount": 3000,
"currency": "SAR",
"timestamp": "2026-10-06T10:46:27.3587531Z"
}
Always contract-scoped. No milestoneId, no contractStatus. On contract.created, previousStatus is none.
When you supplied a reference or metadata at contract creation, those properties are added to data. They are omitted otherwise.
Payment
payment.completed · milestone.payment.completed
Milestone payment:
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"status": "Escrow",
"previousStatus": "Pending",
"contractStatus": "Approved",
"contractPreviousStatus": "Approved",
"amount": 1000,
"currency": "SAR",
"paymentId": "07077237544386819574",
"transactionId": "319499",
"transactionReference": "trans_01M48D6850BDQ7N5RH77K35KZF",
"invoiceId": "7237544",
"timestamp": "2026-10-06T10:47:14.507281Z",
"paymentMethod": "PaymentGateway"
}
Contract payment. Drop milestoneId, contractStatus, and contractPreviousStatus:
"data": {
"contractId": "CNT-01M48FPS2A4R",
"status": "Escrow",
"previousStatus": "Approved",
"amount": 3069,
"currency": "SAR",
"paymentId": "07077237900386852273",
"transactionId": "322683",
"transactionReference": "trans_01M48FR847WMRG9HWQQGYCPPJN",
"invoiceId": "7237900",
"timestamp": "2026-10-06T11:32:01.3714774Z",
"paymentMethod": "PaymentGateway"
}
paymentId, transactionId, and invoiceId are opaque strings. transactionReference is the payment reference (trans_...). Use it later to match a release or a succeeded refund to this payment.
paymentMethod is PaymentGateway for online payment and BankTransfer for a bank transfer.
Bank transfer
bank_transfer.initiated · bank_transfer.buyer_acknowledged
Adds paymentBankInfo, and on acknowledgement buyerAcknowledged and buyerAcknowledgedAt.
See Bank Transfer Events for the full flow.
Payment mismatch
contract.payment.underpaid · contract.payment.overpaid · payment.unmatched · and the milestone.payment.* equivalents
"data": {
"contractId": "CNT-01K5ZR8M2N4Q",
"status": "XXXX",
"previousStatus": "XXXX",
"currency": "SAR",
"paymentMethod": "BankTransfer",
"amountExpected": 1057.5,
"amountReceived": 1000.0,
"amountDifference": -57.5,
"vIBAN": "SA03****************7519",
"transferReference": "PT2609220001",
"senderName": "Kareem Hasan",
"senderIBAN": "SA03********************1234",
"timestamp": "2026-09-22T11:40:00Z"
}
amountDifference is negative when underpaid and positive when overpaid.
Dispute
dispute.created · contract.disputed · milestone.disputed · dispute.resolved · contract.disput.resolved · milestone.disput.resolved
These events do not share one shape. disputeId is a string. raisedBy on an API-opened dispute is ThirdParty.
dispute.created — no status, no amount. Includes milestoneId only for a milestone dispute.
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"disputeId": "774",
"raisedBy": "ThirdParty",
"currency": "SAR",
"timestamp": "2026-10-06T10:47:41.6619642Z"
}
milestone.disputed — milestone status stays Escrow. The contract moves to Dispute in contractStatus.
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"disputeId": "774",
"raisedBy": "ThirdParty",
"status": "Escrow",
"previousStatus": "Approved",
"contractStatus": "Dispute",
"contractPreviousStatus": "Approved",
"amount": 1000,
"currency": "SAR",
"timestamp": "2026-10-06T10:47:41.6542281Z"
}
contract.disputed — status is the contract, and it becomes Dispute. No milestoneId.
"data": {
"contractId": "CNT-01M48FPS2A4R",
"disputeId": "777",
"raisedBy": "ThirdParty",
"status": "Dispute",
"previousStatus": "Escrow",
"amount": 3000.00,
"currency": "SAR",
"timestamp": "2026-10-06T11:32:48.2241167Z"
}
dispute.resolved — disputeDecision and comment only. No status, no amount, no raisedBy. disputeDecision is PartialRefund or FullRelease.
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"disputeId": "774",
"disputeDecision": "PartialRefund",
"currency": "SAR",
"comment": "testing",
"timestamp": "2026-10-06T10:49:57.8658008Z"
}
milestone.disput.resolved — status change. amount is 0. The refunded or released amount is on the refund or release webhook. transactionReference is the original payment reference.
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"disputeId": "774",
"disputeDecision": "PartialRefund",
"status": "PartiallyRefunded",
"previousStatus": "Escrow",
"contractStatus": "Approved",
"contractPreviousStatus": "Dispute",
"amount": 0,
"currency": "SAR",
"comment": "testing",
"transactionReference": "trans_01M48D6850BDQ7N5RH77K35KZF",
"timestamp": "2026-10-06T10:49:57.7678822Z"
}
FullRelease uses the same shape with disputeDecision: "FullRelease" and status: "Released".
contract.disput.resolved — same decision fields, without milestone properties. previousStatus is Dispute.
"data": {
"contractId": "CNT-01M48FPS2A4R",
"disputeId": "777",
"disputeDecision": "PartialRefund",
"status": "PartiallyRefunded",
"previousStatus": "Dispute",
"amount": 0,
"currency": "SAR",
"comment": "testing",
"transactionReference": "trans_01M48FR847WMRG9HWQQGYCPPJN",
"timestamp": "2026-10-06T11:34:12.6705123Z"
}
Refund
refund.full-initiated · refund.full-succeeded · refund.partial-initiated · refund.partial-succeeded · the milestone.refund.* equivalents · refund_request.cancelled · overpaid.refund.succeeded · underpaid.refund.succeeded · unmatched.refund.succeeded
Partial refund initiated while resolving a contract dispute. status is PartiallyRefunded. amount is the amount being refunded. transactionId is the refund id.
"data": {
"contractId": "CNT-01M48FPS2A4R",
"status": "PartiallyRefunded",
"previousStatus": "Dispute",
"amount": 2000,
"currency": "SAR",
"transactionId": "1425",
"timestamp": "2026-10-06T11:34:12.6915178Z"
}
Milestone partial refund initiated. milestoneId is present on data, and repeated under metadata.
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"status": "PartiallyRefunded",
"previousStatus": "Escrow",
"contractStatus": "Approved",
"contractPreviousStatus": "Dispute",
"amount": 400,
"currency": "SAR",
"transactionId": "1424",
"timestamp": "2026-10-06T10:49:57.9475973Z",
"metadata": {
"milestoneId": "MST-01M48D4SZHR5"
}
}
milestone.refund.partial-succeeded is sent when that refund settles. It carries transactionReference (the original payment reference from milestone.payment.completed), not transactionId.
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZHR5",
"status": "PartiallyRefunded",
"previousStatus": "Escrow",
"contractStatus": "Approved",
"contractPreviousStatus": "Dispute",
"amount": 400.00,
"currency": "SAR",
"transactionReference": "trans_01M48D6850BDQ7N5RH77K35KZF",
"timestamp": "2026-10-06T10:53:15.5574041Z"
}
On *-initiated, transactionId is the refund identifier. On milestone.refund.partial-succeeded, match the original payment with transactionReference.
Refunds raised against a mismatched payment also include a nested refund object:
"refund": {
"id": 4521,
"amount": 57.5,
"status": "Succeeded"
}
Release
contract.released · milestone.released
milestone.released after a FullRelease decision. It does not include paymentId, transactionId, or invoiceId. amount is the released amount. transactionReference matches the original payment.
"data": {
"contractId": "CNT-01M48D4SZYAW",
"milestoneId": "MST-01M48D4SZH53",
"status": "Released",
"previousStatus": "Escrow",
"contractStatus": "Approved",
"contractPreviousStatus": "Dispute",
"amount": 1000,
"currency": "SAR",
"transactionReference": "trans_01M48DJXFQ731SF5PZKVHN450B",
"timestamp": "2026-10-06T10:55:14.2792703Z"
}
Property matrix
| Property | Appears on |
|---|---|
contractId | every event |
currency | every event |
timestamp | every event |
status, previousStatus | status-changing events. Absent on dispute.created and dispute.resolved |
reference, metadata | when you supplied them at contract creation. metadata.milestoneId is also sent on milestone.refund.partial-initiated |
amount | most events. Absent on dispute.created and dispute.resolved. 0 on contract.disput.resolved and milestone.disput.resolved |
milestoneId | milestone-scoped events, including dispute.created and dispute.resolved when the dispute is on a milestone |
contractStatus, contractPreviousStatus | milestone payment, disputed, disput-resolved, refund, and release events. Absent on dispute.created and dispute.resolved |
paymentId, invoiceId | payment.completed and milestone.payment.completed |
transactionId | payment completed events, and refund *-initiated (the refund id) |
transactionReference | payment completed, *.disput.resolved, milestone.released, and milestone.refund.partial-succeeded |
paymentMethod | payment completed events. Also bank transfer and mismatch events |
paymentBankInfo | bank transfer events |
buyerAcknowledged, buyerAcknowledgedAt | bank_transfer.buyer_acknowledged |
amountExpected, amountReceived, amountDifference | mismatch events |
vIBAN, transferReference, senderName, senderIBAN | mismatch events |
refund | mismatch refund events and refund_request.cancelled |
disputeId | dispute events |
raisedBy | dispute.created, contract.disputed, milestone.disputed |
disputeDecision | dispute.resolved, contract.disput.resolved, milestone.disput.resolved. Values: PartialRefund, FullRelease |
comment | the same resolved dispute events as disputeDecision |
What to read, by use case
| You want to know | Read |
|---|---|
| Is the whole contract paid? | contractStatus when it is present, otherwise status. dispute.created and dispute.resolved have neither |
| Is this one milestone paid? | status, on an event where contractStatus is also present |
| Which milestone does this concern? | milestoneId |
| How did the buyer pay? | paymentMethod |
| Did the money actually arrive? | payment.completed or milestone.payment.completed. Never bank_transfer.buyer_acknowledged |
| Was the amount wrong? | amountDifference on a mismatch event |
| Where do I show transfer details? | paymentBankInfo on bank_transfer.initiated |
| Have I already processed this? | id, or the X-WePay-Webhook-Id header |
| Which of my records is this? | reference and metadata, when you supplied them |
| Who raised the dispute? | raisedBy on dispute.created or *.disputed |
| How was the dispute decided? | disputeDecision on dispute.resolved (PartialRefund or FullRelease) |
| What note was attached to the resolution? | comment |
| Which payment does a release or succeeded refund belong to? | transactionReference |
| How much was refunded or released? | amount on the refund or release webhook, not on *.disput.resolved |