Webhooks
Webhooks let MassPay notify your systems in real time instead of you polling our API. Every time a transaction, user, or verification object changes state, we send an HTTP POST to a URL you control.
On this page
- Getting started
- Registering and managing webhooks
- Verifying signatures
- Delivery, retries, and idempotency
- Event reference
Getting started
1. Build an endpoint
Your endpoint must:
- Be reachable over HTTPS on the public internet (no self-signed certificates).
- Accept
POSTrequests with aContent-Type: application/jsonbody. - Return a
2xxstatus code as soon as the payload is received — within 3 seconds in production. Do the actual work asynchronously — see Respond fast. - Accept traffic from MassPay's source IP addresses, if you restrict inbound traffic by IP.
2. Register it
Create a webhook configuration through the API, in the client dashboard, or by sending the URL to your account representative.
3. Store the signing secret
Creating a webhook returns a webhook_secret. Store it in your secret manager immediately — it is used to verify every payload that webhook delivers, and it cannot be retrieved later. Never commit it to source control or expose it client-side.
4. Test
Trigger a test event and confirm your endpoint returns 2xx and that signature verification passes before going live.
Registering and managing webhooks
You can register multiple webhooks per account, each subscribed to a different set of events — for example, one endpoint for payout and payin events and a separate one for KYC and document events.
Requests are authenticated the same way as the rest of the API. See Authentication.
Base URL: https://{environment}.masspay.io/{VERSION}/payout/account/webhooks
| Action | Method and path | Reference |
|---|---|---|
| Create a webhook | POST /payout/account/webhooks | Create a webhook configuration |
| List webhooks | GET /payout/account/webhooks | Get webhooks |
| Delete a webhook | DELETE /payout/account/webhooks/{webhook_token} | Delete a webhook configuration |
Create a webhook
Provide a target URL and the list of events it should receive. On success you get back a webhook_token for managing the configuration, and a webhook_secret for verifying payloads.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
webhook_url | string | Yes | The HTTPS URL that will receive the payloads. |
event_types | array of strings | Yes | The topics this webhook subscribes to. Must be values from the subscription topics list. |
curl --request POST \
--url https://api.masspay.io/v1.0.0/payout/account/webhooks \
--header 'accept: application/json' \
--header 'content-type: application/json' \
--data '{
"webhook_url": "https://example.com/masspay/webhooks",
"event_types": ["payout.created", "payout.status", "payin.status", "user.status"]
}'201 Created
{
"webhook_token": "webhook_3f8c2a91-5d47-4b6e-9a10-c72e5b4d81f0",
"webhook_secret": "29f67213-29a9-44d3-abf6-c4441ad07bc9"
}| Field | Type | Description |
|---|---|---|
webhook_token | string | Identifies the webhook configuration. Use it to delete the webhook. |
webhook_secret | string | The key for verifying signatures on everything this webhook delivers. |
The response does not repeat webhook_url or event_types. Read them back with List webhooks.
Save
webhook_secretnow. It is returned only on creation and is not available fromGET /payout/account/webhooks. If you lose it, delete the webhook and create a new one.
List webhooks
Returns every webhook configuration on the account with its URL and subscribed events. No parameters required.
curl --request GET \
--url https://api.masspay.io/v1.0.0/payout/account/webhooks \
--header 'accept: application/json'200 OK
[
{
"webhook_url": "https://example.com/masspay/webhooks",
"token": "webhook_3f8c2a91-5d47-4b6e-9a10-c72e5b4d81f0",
"event_types": ["payout.created", "payout.status", "payin.status", "user.status"]
}
]In this response the webhook's token is returned in token. It is the same value that creation returns as webhook_token.
Delete a webhook
Pass the webhook_token as a path parameter. The webhook stops receiving events immediately and returns 204 No Content. If the token does not match an active webhook on your account, the response is 404 Not Found.
curl --request DELETE \
--url https://api.masspay.io/v1.0.0/payout/account/webhooks/webhook_3f8c2a91-5d47-4b6e-9a10-c72e5b4d81f0 \
--header 'accept: application/json'Rotating a URL or secret
There is no update endpoint. To change a webhook's URL, secret, or event subscriptions:
- Create a new webhook with the desired configuration.
- Deploy your endpoint so it accepts both the old and new secrets.
- Confirm events are arriving on the new configuration.
- Delete the old webhook, then drop support for the old secret.
Running both briefly means you will receive duplicate events during the overlap — another reason handlers should be idempotent.
Verifying signatures
Every webhook includes an X-MassPay-Signature header. Verify it on every request — an unverified endpoint will accept forged payloads from anyone who learns your URL.
Header format
X-MassPay-Signature: t=1754500143412,s=3a1f9c...e7b2
| Element | Description |
|---|---|
t | Unix timestamp in milliseconds at which the signature was generated. |
s | Lowercase hex-encoded HMAC SHA-512 of the signed payload. |
Signed payload
Concatenate three things, in order:
<t value> + "|" + <raw request body>
Use the raw body bytes, exactly as received. Deserializing the JSON and re-serializing it will change key order and whitespace, and the signature will not match. Most frameworks require explicit configuration to preserve the raw body (for example,
express.raw()in Express orrequest.get_data()in Flask).
Compute the HMAC of that string using the webhook_secret for the webhook configuration that sent the request, then compare it to s using a constant-time comparison function.
If you have registered multiple webhooks, each has its own secret. Route by endpoint path so each handler verifies against the right one.
Timestamp tolerance
Reject requests whose t value is more than 5 minutes away from your current time. This limits how long a captured payload can be replayed. Make sure your server clock is synced via NTP.
t is in milliseconds. Convert before comparing it with a clock that reports seconds, and use the value exactly as received when building the signed payload.
Every delivery attempt is signed at the moment it is sent, so a retry carries a fresh t and a new s and passes this check like a first attempt.
Examples
Node.js
const crypto = require("crypto");
function verifyMassPaySignature(header, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
);
const timestamp = parts.t;
const received = parts.s;
if (!timestamp || !received) return false;
// Reject stale or future-dated requests. `t` is in milliseconds.
const age = Math.abs(Date.now() - Number(timestamp)) / 1000;
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha512", secret)
.update(`${timestamp}|${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hashlib
import hmac
import time
def verify_masspay_signature(header, raw_body, secret, tolerance_seconds=300):
parts = dict(kv.strip().split("=", 1) for kv in header.split(","))
timestamp, received = parts.get("t"), parts.get("s")
if not timestamp or not received:
return False
# Reject stale or future-dated requests. `t` is in milliseconds.
if not timestamp.isdigit():
return False
if abs(time.time() * 1000 - int(timestamp)) / 1000 > tolerance_seconds:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}|{raw_body}".encode("utf-8"),
hashlib.sha512,
).hexdigest()
return hmac.compare_digest(expected, received)PHP
function verify_masspay_signature($header, $raw_body, $secret, $tolerance = 300) {
parse_str(str_replace(',', '&', $header), $parts);
if (empty($parts['t']) || empty($parts['s'])) {
return false;
}
// Reject stale or future-dated requests. `t` is in milliseconds.
if (!ctype_digit($parts['t'])) {
return false;
}
if (abs(microtime(true) * 1000 - (float) $parts['t']) / 1000 > $tolerance) {
return false;
}
$expected = hash_hmac('sha512', $parts['t'] . '|' . $raw_body, $secret);
return hash_equals($expected, $parts['s']);
}Delivery, retries, and idempotency
| Behavior | Production | Sandbox |
|---|---|---|
| Success response | Any 2xx | Any 2xx |
| Request timeout | 3 seconds | 30 seconds |
| Delivery attempts | Up to 6 — the first attempt plus 5 retries | Same |
| Retry schedule | One attempt about every hour; the last is about 5 hours after the first | Same |
| After final failure | The event is dropped. The webhook stays active, and no failure notice is sent. | Same |
| Delivery guarantee | Best effort, with retries. An event can arrive more than once; an event whose attempts all fail is not delivered. | Same |
| Ordering | Not guaranteed | Not guaranteed |
| Source IPs | 54.185.181.94 | 34.223.172.29 |
Production is api.masspay.io. Sandbox is staging-api.masspay.io.
What counts as a failed delivery
A delivery attempt fails when any of these happens:
- Your endpoint returns a status code outside
2xx. Do not rely on redirects — register the final URL. - The connection is refused, or cannot be established within the timeout.
- The TLS handshake fails, for example because the certificate is expired, self-signed, or issued for a different hostname.
- Your endpoint accepts the connection but does not respond within the timeout.
- The hostname in your webhook URL cannot be resolved in DNS. This case also disables the webhook.
Request timeout
In production, MassPay allows 3 seconds to connect to your endpoint and 3 seconds for it to respond. If either limit is exceeded, the attempt is abandoned and counted as failed — even if your handler goes on to finish its work.
Sandbox allows 30 seconds for each. A handler that is comfortably fast in sandbox can still time out in production, so measure your response time against the production limit before going live.
Retry schedule
A failed delivery is retried up to 5 times, for a maximum of 6 attempts per event. The interval does not grow between attempts: each retry is sent about one hour after the previous attempt failed, plus a short random delay.
| Attempt | Sent | Random delay added |
|---|---|---|
| 1 | Within seconds of the event | — |
| 2 | About 1 hour after attempt 1 | Up to 4 seconds |
| 3 | About 2 hours after attempt 1 | Up to 8 seconds |
| 4 | About 3 hours after attempt 1 | Up to 16 seconds |
| 5 | About 4 hours after attempt 1 | Up to 32 seconds |
| 6 (final) | About 5 hours after attempt 1 | Up to 64 seconds |
Treat these times as approximate. Queueing and processing add a few seconds of variance to every attempt.
What to expect from a retry:
- The body is identical to the original attempt, including
occurred_at. The signature is not: every attempt is signed when it is sent, sotandschange. - Nothing marks a request as a retry. There is no attempt counter and no delivery ID, in the headers or in the body.
- Each webhook is retried on its own. If several of your webhooks subscribe to the same topic and one fails, only that one is retried.
- Retries stop as soon as one attempt succeeds. They are also cancelled when you delete the webhook, unless another of your active webhooks uses the same URL.
An outage shorter than about 5 hours is normally covered by retries: the events arrive late, and possibly out of order. During a longer outage, events whose attempts all fall inside it are dropped.
After the final attempt fails
If all 6 attempts fail, MassPay stops trying to deliver that event:
- The event is dropped. It is not queued for later, and it is not re-sent when your endpoint recovers.
- You are not notified. No email, alert, or follow-up webhook reports that an event was dropped.
- The webhook stays active. New events continue to be sent to it, each with its own full set of attempts.
What you need to do:
- Restore your endpoint so that it returns
2xxwithin the timeout. - Confirm the webhook is still registered with
GET /payout/account/webhooks. If it is missing, it was disabled — create a new one. - Reconcile what changed while your endpoint was unavailable. For every object you were tracking that had not reached a final status, fetch its current state from the API — for example Get status of a payout by payout token, Transactions history, or Get user by user token.
Because a dropped event is silent, do not make webhooks your only source of truth. Run the reconciliation in step 3 on a schedule, not only after an outage you know about.
Automatic disabling
MassPay does not disable a webhook because deliveries fail or time out. A webhook that fails every attempt stays registered and keeps being sent new events.
The one exception is DNS. If the hostname in your webhook_url cannot be resolved, every webhook you registered with that URL is disabled on that attempt, without waiting for retries:
- It stops receiving events, and its pending retries are cancelled.
- It no longer appears in
GET /payout/account/webhooks. - It cannot be re-enabled. Once DNS resolves again, create a new webhook and store the new
webhook_secret.
Keep the DNS record for your webhook hostname in place while you migrate or re-point it. Even a brief lapse disables the webhook.
Source IP addresses
Webhooks are sent from one static address per environment. If you restrict inbound traffic by IP, allow these:
| Environment | IP address | CIDR |
|---|---|---|
Production (api.masspay.io) | 54.185.181.94 | 54.185.181.94/32 |
Sandbox (staging-api.masspay.io) | 34.223.172.29 | 34.223.172.29/32 |
Allowlisting is optional, and it does not replace signature verification. Treat a request as genuine only after its signature verifies.
Respond fast
Acknowledge with a 2xx immediately, then queue the payload for processing. If your handler does its work before responding and exceeds the timeout — 3 seconds in production — we treat the delivery as failed and retry it, even though you already processed it.
Expect duplicates
Retries and network conditions mean the same event can arrive more than once. Make your handler idempotent: key off the token in the payload (payout_token, payin_token, load_token, and so on) plus status, and ignore anything you have already applied. Requests carry no delivery ID, so deduplicate on the payload itself.
Handle out-of-order events
Events are not guaranteed to arrive in the order they occurred. Every payload carries an occurred_at timestamp — compare it against the last one you stored for that object and discard anything older. Do not infer ordering from arrival time.
Verify before trusting
Treat the payload as a notification, not as authoritative state. For anything financially significant, verify against the corresponding API endpoint before releasing funds or marking a record final.
Reading an object through the API does not send a webhook, unless you pass force_status_update=true. A forced payout or payin status read asks the provider for the current status and then sends the payout.status or payin.status webhook every time the provider answers, whether or not the status changed. So call Get status of a payout by payout token or Get status of a payin from inside your webhook handler without force_status_update: you get the stored state and cause no event. A handler that forces a refresh on every event sends itself the next event, and loops.
Event reference
Every payload includes:
| Field | Type | Description |
|---|---|---|
event_type | string | Identifies which event fired. Switch on this value. |
occurred_at | string | RFC 3339 UTC timestamp of when the event occurred. |
Handle unrecognized event_type values gracefully — new events are added over time, and a handler that throws on an unknown type will start failing after a release.
Three conventions apply to every payload:
-
Empty fields are left out. A field that has no value is omitted rather than sent as
null. Check that a key exists before reading it. -
Whole numbers are usually sent without a decimal point. An amount of one hundred arrives as
100. Compare amounts as numbers, not as text. -
Some characters are sent as Unicode escapes. The equals sign, ampersand, angle brackets, and apostrophe are written as
\u-escapes rather than literally, so a URL arrives like this:{"link":"https://example.com/verify?session=abc&lang=en"}Any JSON parser decodes them. They are part of the raw body, so they are part of what you verify the signature against.
The examples on this page are formatted for reading. The body you receive is a single line with no extra whitespace.
All events
The values below are what you pass in event_types when creating a webhook. A webhook only receives the topics it is subscribed to; if an expected event never arrives, check the subscription with GET /payout/account/webhooks before investigating your endpoint.
Subscription topics and payload event_type values are two different vocabularies. You subscribe to a topic; the payload that arrives identifies itself with an event_type. Some topics deliver more than one event_type. Always branch on the event_type in the body, not on what you subscribed to.
Subscription topics
| Topic | Delivers | Payload event_type |
|---|---|---|
payout.created | A payout is created | payout |
payout.status | A payout changes status | payout |
payout_reversal.created | A reversal is created against a payout | payout_reversal_created |
payout_reversal.status | A payout reversal changes status | payout_reversal_status, payout_reversal_update |
payin.created | A payin is created | payin_status |
payin.status | A payin changes status | payin_status |
payin_deposit.returned | A return is initiated for a deposit against a payin | payin_deposit.returned |
payin_deposit.return_completed | A deposit against a payin is returned | payin_deposit_return_completed |
load.created | A load is created, or funds are transferred between wallets | load, wallet.transfer |
load.status | A load completes, or a scheduled load is processed | load.status, processed_scheduled_load |
load.reversed | A load is cancelled or reversed | load_reversal |
spendback.created | A spendback is created | spendback |
spendback.status | A spendback changes status | spendback |
spendback.inactivity | An inactivity spendback is applied | inactivity_spendback |
user.created | A user is created | user_created |
user.status | A user's status changes | user_status_update |
business.created | A business is created | business_created |
business.status | A business's status changes | business_status_update |
kyc.id | Identity verification activity, including forwarded ID provider payloads | id_requested, plus one per identity provider: veriff, evs, persona, sumsub, ondato, shuftipro, stripe, aiprise, yoti, plaid, didit |
kyc.tax | Tax forms, tax interviews, and TIN matching activity | tax_form, tax_wh_interview, tax_tin_matching, tax_identity |
file_upload_req.created | A file upload link is created | file_upload_req_created |
file_upload_req.status | A file is uploaded against a link, or uploaded files are rejected | file_upload_req_uploaded, file_upload_req_rejected |
file_upload_req.verified | An uploaded file is verified | file_upload_req_verified |
avs.completed | Account ownership verification completes | avs.completed |
attributes.stored | User attributes are stored | stored_attrs |
deposit_account.created | A deposit account is issued | new_deposit_account |
balance.credit | Your account balance is credited | balance_credit |
Payload event_type values
event_type valuesThis is what arrives in the body. Jump to any event for its full payload.
event_type | Category | Fires when |
|---|---|---|
payout | Payouts | A payout transaction is created or changes status |
payout_reversal_created | Payouts | A reversal is created against a payout |
payout_reversal_status | Payouts | A payout reversal changes status |
payout_reversal_update | Payouts | A payout reversal is updated by the banking network |
payin_status | Payins | A payin is created or changes status |
payin_deposit.returned | Payin deposits | A return is initiated for a deposit against a payin |
payin_deposit_return_completed | Payin deposits | A deposit against a payin is returned |
load | Loads | A load is created |
load.status | Loads | A load completes |
processed_scheduled_load | Loads | A scheduled load is processed |
load_reversal | Loads | A load is cancelled or reversed |
wallet.transfer | Loads | Funds are transferred between wallets |
spendback | Loads | A spendback is created or changes status |
inactivity_spendback | Loads | An inactivity spendback is applied |
user_created | Users | A user is created |
user_status_update | Users | A user's status changes |
business_created | Businesses | A business is created |
business_status_update | Businesses | A business's status changes |
stored_attrs | Attributes | User attributes are stored |
id_requested | Verification | ID verification is requested from a user |
veriff | Verification | Veriff returns a verification decision |
evs | Verification | EVS returns the result of an identity check |
persona, sumsub, ondato, shuftipro, stripe, aiprise, yoti, plaid, didit | Verification | The named identity provider returns a result |
avs.completed | Verification | Account ownership verification completes |
file_upload_req_created | Documents | A file upload link is created |
file_upload_req_uploaded | Documents | A file is uploaded against a link |
file_upload_req_rejected | Documents | Uploaded files are rejected and a replacement link is created |
file_upload_req_verified | Documents | An uploaded file is verified |
tax_form | Tax | A tax form is submitted |
tax_wh_interview | Tax | A tax interview is completed |
tax_tin_matching | Tax | TIN matching returns a result |
tax_identity | Tax | A tax identity interview returns a result |
new_deposit_account | Account | A deposit account is issued |
balance_credit | Account | Your account balance is credited |
Payouts
payout
payoutSent when a payout is created and on every subsequent status update, including updates that leave status unchanged. Subscribe to payout.created, payout.status, or both — the payload shape is identical; use status to tell them apart.
{
"status_change_time": "2026-07-29T15:25:42.000Z",
"payout_token": "payout_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8",
"event_type": "payout",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"client_transfer_id": "aEjn345",
"source_currency_code": "USD",
"destination_currency_code": "MXN",
"source_token": "clnt_wlt_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"destination_token": "dest_d2138fd0-00be-45a8-985f-4f5bde500962",
"destination_amount": 1864.28,
"source_amount": 100.5,
"reversible_amount": 100.5,
"attr_set_token": "attr_set_b1a867c1-6e36-4525-b6d5-a20bac80e3b0",
"exchange_rate": 18.55,
"fee": 2.99,
"expiration": "2026-08-28T05:40:58.475Z",
"pickup_code": "4417209853",
"trace_code": "021000021987654",
"status": "COMPLETED",
"country_code": "MEX",
"delivery_type": "BANK_DEPOSIT",
"payer_name": "Bank Deposit",
"metadata": {
"order_id": "A-1001"
},
"attrs": {}
}Notes
destination_amountissource_amount × exchange_rate.feeis charged in the source currency and is not deducted fromdestination_amount.- Use
client_transfer_idto reconcile against your own records. user_tokenis sent on status changes. The creation event does not carry it.source_tokencan arrive with or without its wallet prefix (clnt_wlt_,usr_wlt_, ormp_card_). Compare on the UUID.reversible_amountis the part of the payout that can still be reversed.client_transfer_id,pickup_code,trace_code,payer_name, andmetadataare included only when they have a value.attrsis always an empty object on this event. Fetch the values with Get user attributes using theattr_set_token.- The reason for a failed or held payout is not part of this event. Read
status_reasonfrom Get status of a payout by payout token. - The same status is announced more than once: every update to the payout sends the event, whether or not
statuschanged. Apply an event only whenstatusdiffers from what you have stored — see Expect duplicates.
payout_reversal_created
payout_reversal_createdSent when a reversal is created against an existing payout. Subscribe to payout_reversal.created. Reversals may be partial.
{
"payout_reversal_token": "payout_rev_00900dc5-de31-4d37-8ba2-6f7460b19fb0",
"source_amount": 100.5,
"source_currency_code": "USD",
"exchange_rate": 1,
"destination_amount": 100.5,
"destination_currency_code": "USD",
"status": "PROCESSING",
"payout_token": "payout_ea4b5932-91f0-4b91-9590-b27f6d666dcd",
"metadata": {
"key": "value"
},
"fee": 1.5,
"client_transfer_id": "Reversal54543",
"original_destination_amount": 250.0,
"total_reversed_amount": 100.5,
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "payout_reversal_created"
}Notes
original_destination_amountis the full amount of the original payout;destination_amountis the amount of this reversal.total_reversed_amountis the cumulative amount reversed against the payout, including this reversal. In the example above, 100.50 of an original 250.00 has now been reversed.
payout_reversal_status
payout_reversal_statusSent when a payout reversal completes. Subscribe to payout_reversal.status. The same topic also delivers payout_reversal_update, so branch on event_type.
{
"payout_reversal_token": "payout_rev_5266a2a3-8811-4f78-9fc2-c97a2706584b",
"user_token": "usr_ce29989d-6764-11f0-a026-06178c1a380f",
"source_amount": 214.99,
"original_destination_amount": 232.01,
"source_currency_code": "USD",
"exchange_rate": 1,
"destination_amount": 214.99,
"total_reversed_amount": 214.99,
"destination_currency_code": "USD",
"status": "COMPLETED",
"client_transfer_id": "porev_pnc560IDoY46",
"payout_token": "payout_2ec83f4c-894c-11f0-8023-0266f44cc279",
"metadata": {
"clawback_request_id": "claw_GQAZp3FCFjAsF"
},
"fee": 1,
"event_type": "payout_reversal_status",
"occurred_at": "2026-08-06T16:00:09.778113446Z",
"created": "2026-08-01T15:32:17",
"completed": "2026-08-06T16:00:09"
}Notes
- Match on
payout_reversal_tokento update the record created bypayout_reversal_created. - This event carries
user_token,created, andcompleted, which the creation event does not. statusis alwaysCOMPLETEDon this event.createdandcompletedare UTC, written without a time zone suffix or fractional seconds.- In the example above, 214.99 of an original 232.01 payout has been reversed, leaving 17.02 outstanding.
payout_reversal_update
payout_reversal_updateSent when the banking network changes the state of a payout reversal — for example, when the debit that funds the reversal is sent, or when it is returned. Subscribe to payout_reversal.status.
{
"payout_reversal_token": "payout_rev_5266a2a3-8811-4f78-9fc2-c97a2706584b",
"source_amount": 214.99,
"original_destination_amount": 232.01,
"source_currency_code": "USD",
"exchange_rate": 1,
"destination_amount": 214.99,
"total_reversed_amount": 0,
"destination_currency_code": "USD",
"status": "CANCELLED",
"status_reason": "R01 - Insufficient Funds",
"client_transfer_id": "porev_pnc560IDoY46",
"payout_token": "payout_2ec83f4c-894c-11f0-8023-0266f44cc279",
"user_token": "usr_ce29989d-6764-11f0-a026-06178c1a380f",
"metadata": {
"clawback_request_id": "claw_GQAZp3FCFjAsF"
},
"fee": 1,
"event_type": "payout_reversal_update",
"occurred_at": "2026-08-06T16:00:09.778113446Z"
}Notes
statusisREADY_FOR_PICKUPwhen the debit has been sent to the banking network, andCANCELLEDwhen the network returned or cancelled it.status_reasonis sent only when the network returned the debit. It carries the network's return code and its description. Treat it as display text and do not parse it.client_transfer_idandmetadataare included only when they have a value.- This event has no
createdorcompleted. Those arrive onpayout_reversal_status.
Payins
payin_status
payin_statusSent when a payin is created and on every subsequent status change. Subscribe to payin.created, payin.status, or both. The deposits array contains the individual deposits applied against the payin.
{
"payin_token": "payin_7264211a-a946-4193-afaf-2e0b16976866",
"event_type": "payin_status",
"client_transfer_id": "Invoice #1343",
"source_currency_code": "USD",
"destination_currency_code": "USD",
"source_token": "orgn_35b2feae-2be7-44df-bdc7-fa7114a36dee",
"destination_token": "clnt_wlt_3256e586-cdb1-433e-8390-687ee771d60d",
"destination_amount": 12.1,
"source_amount_collected": 12.1,
"source_amount": 12.1,
"exchange_rate": 1,
"fee": 0.06,
"time_initiated": "2026-07-17T06:43:02.248Z",
"funding_code": "87108cc0-8c8f-41ae-a66f-3b20391d432e",
"status": "COMPLETED",
"country_code": "USA",
"delivery_type": "BANK_DEPOSIT",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"payer_name": "Bank Deposit",
"deposits": [
{
"token": "payin_dep_255f64a2-d026-49a3-abf3-143d261db741",
"external_id": "4d035e3d-9618-4c26-b343-6d50a2ffad15",
"status": "COMPLETED",
"deposit_amount": 12.1,
"deposit_currency": "USD",
"fiat_amount": 12.1,
"fee": 0.06,
"fiat_currency": "USD",
"transaction_reference": "021000021987654",
"metadata": "{\"deposit_source\":\"fedwire\"}",
"created_at": "2026-07-17T06:43:12",
"completed_at": "2026-07-17T06:43:12",
"updated_at": "2026-07-17T06:43:12"
}
]
}Notes
source_amountis the expected amount;source_amount_collectedis what actually arrived. They can differ when a payer under- or over-funds.- A payin can be satisfied by multiple deposits. Reconcile on
deposits[].token, not on array position.depositsis an empty array until the first deposit arrives. - The creation event and the status events share this payload. On creation,
statusis the status the payin was created in. deposits[].metadatais a JSON-encoded string, not an object. Parse it a second time, and guard against it being absent.deposits[].metadatacan hold the depositor's bank account details. Exclude it from application logs and error-reporting tools.- A deposit that was returned can also carry the return fields listed under
payin_deposit.returned. client_transfer_id,funding_code,attr_set_token,payer_name, andmetadataare included only when they have a value.- Deposit timestamps are written without a time zone suffix.
Payin deposits
Deposit-level events describe the individual deposits that fund a payin. Each corresponds to an entry in the deposits array of a payin_status event.
A return produces two events, on two topics:
| Step | Topic | Payload event_type |
|---|---|---|
| The return is initiated | payin_deposit.returned | payin_deposit.returned |
| The banking network settles the return | payin_deposit.return_completed | payin_deposit_return_completed |
payin_deposit.returned
payin_deposit.returnedSent when a return is initiated for an individual deposit against a payin. Subscribe to payin_deposit.returned. The deposit is marked RETURNED and the funds are debited from your balance. payin_deposit_return_completed follows when the banking network settles the return.
{
"token": "payin_dep_7d3b0c15-9a2f-4e6b-8c41-2f5d9e8a7b64",
"external_id": "4987aa65-8449-41f6-b828-b4a100fcec2c",
"status": "RETURNED",
"deposit_amount": 100,
"deposit_currency": "USD",
"fiat_amount": 100,
"fiat_currency": "USD",
"fee": 0,
"transaction_reference": "20260805I1B7031R00019876",
"return_reason_code": "REFUSED_BY_BENEFICIARY",
"network_return_code": "CUST",
"return_reason": "Sender not recognized; customer refused the credit",
"return_fee": 1,
"fee_payer": "DEPOSITOR",
"returned_at": "2026-08-09T11:20:51",
"created_at": "2026-08-05T09:15:33",
"completed_at": "2026-08-05T09:15:34",
"updated_at": "2026-08-09T11:20:51",
"event_type": "payin_deposit.returned",
"payin_token": "payin_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"user_token": "usr_1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718",
"occurred_at": "2026-08-09T11:20:51.412751752Z"
}Return fields
| Field | Description |
|---|---|
return_reason_code | MassPay's normalized reason for the return. One of REFUSED_BY_BENEFICIARY, UNEXPECTED_PAYMENT, DUPLICATE_DEPOSIT, ACCOUNT_CLOSED, ACCOUNT_NOT_FOUND, INVALID_ACCOUNT_NUMBER, BENEFICIARY_NAME_MISMATCH, ACCOUNT_FROZEN, NON_TRANSACTION_ACCOUNT, SUSPECTED_FRAUD, REGULATORY_COMPLIANCE, SENDER_RECALL_REQUEST, BENEFICIARY_DECEASED, or OTHER. |
network_return_code | The raw code from the banking network, such as an ACH return code or a wire reason code. Omitted when the network did not report one. |
return_reason | Human-readable explanation. Suitable for display or support tooling, but do not branch on its text. Omitted when none was given. |
return_fee | Fee charged for processing the return, separate from the original fee. |
fee_payer | Who bears return_fee: CLIENT or DEPOSITOR. |
returned_at | When the return was initiated. |
Notes
tokenis the deposit token. It matches an entry in thedepositsarray of the correspondingpayin_statusevent — use it to locate the record to reverse, andpayin_tokento find the parent payin.- A returned deposit does not necessarily return the whole payin. A payin satisfied by several deposits can have one returned while the others stand; recompute the payin's funded position rather than assuming it is now unfunded.
completed_atremains the original completion time.returned_atandupdated_atare when the return was initiated.returned_at,created_at,completed_at, andupdated_atare written without a time zone suffix.external_idandtransaction_referenceare included only when they have a value.- This event carries no
metadataand notrace_code. Both arrive onpayin_deposit_return_completed, which repeats the return fields.
payin_deposit_return_completed
payin_deposit_return_completedSent when the banking network settles the return of an individual deposit. Subscribe to payin_deposit.return_completed.
The topic is written with dots and the payload's event_type with underscores. Subscribe with payin_deposit.return_completed; branch on payin_deposit_return_completed.
{
"token": "payin_dep_7d3b0c15-9a2f-4e6b-8c41-2f5d9e8a7b64",
"external_id": "4987aa65-8449-41f6-b828-b4a100fcec2c",
"status": "RETURNED",
"deposit_amount": 100,
"deposit_currency": "USD",
"fiat_amount": 100,
"fee": 0,
"fiat_currency": "USD",
"transaction_reference": "20260805I1B7031R00019876",
"metadata": "{\"deposit_source\":\"fedwire\"}",
"created_at": "2026-08-05T09:15:33",
"completed_at": "2026-08-05T09:15:34",
"updated_at": "2026-08-09T11:20:51",
"return_reason_code": "REFUSED_BY_BENEFICIARY",
"network_return_code": "CUST",
"return_reason": "Sender not recognized; customer refused the credit",
"return_fee": 1,
"fee_payer": "DEPOSITOR",
"returned_at": "2026-08-09T11:20:51",
"event_type": "payin_deposit_return_completed",
"payin_token": "payin_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"user_token": "usr_1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718",
"trace_code": "20260810MFP00057000244",
"occurred_at": "2026-08-09T21:05:25.043Z"
}Notes
- Match on
tokento thepayin_deposit.returnedevent you received when the return was initiated. - The return details —
return_reason_code,network_return_code,return_reason,return_fee,fee_payer, andreturned_at— are described under Return fields. They are included when MassPay has them. Do not require them on this event. returned_atis when the return was initiated.occurred_atis when the banking network settled it.trace_codeis the network trace identifier for the return, distinct fromtransaction_referenceon the original deposit. Quote it when raising a case with the network or with support. It is included only when the network reported one.external_idandtransaction_referenceare included only when they have a value.- Timestamps other than
occurred_atare written without a time zone suffix.
metadatais a JSON-encoded string, not an object — note the escaped quotes in the example. Parse it a second time, and guard against it being absent or empty. It can hold the depositor's bank account details, so exclude it from application logs and error-reporting tools.
Loads
A load produces up to three events. Which ones you receive depends on whether the load runs immediately or is scheduled:
| When | Topic | Payload event_type |
|---|---|---|
| The load is created | load.created | load |
| The load completes as it is created | load.status | load.status |
| A scheduled load reaches its time and is processed | load.status | processed_scheduled_load |
load
loadSent when a load is created. Subscribe to load.created.
{
"load_token": "ld_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"status": "COMPLETED",
"event_type": "load",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8",
"wallet_token": "usr_wlt_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"metadata": {
"invoice": "INV-1001"
}
}Notes
statusisCOMPLETEDfor a load that runs immediately andSCHEDULEDfor one with a futuretime_of_load.- The payload does not carry the amount, currency, or
client_load_id. Look the load up with Get history of loads by user token usinguser_token. metadatais included only when the load was created with it.
load.status
load.statusSent when a load completes as it is created. Subscribe to load.status. It follows the load event for the same load and carries the same fields.
{
"load_token": "ld_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"status": "COMPLETED",
"event_type": "load.status",
"occurred_at": "2026-07-29T15:25:43.618204117Z",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8",
"wallet_token": "usr_wlt_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"metadata": {
"invoice": "INV-1001"
}
}Notes
- This event is not sent for a scheduled load. A scheduled load reports its outcome with
processed_scheduled_load.
processed_scheduled_load
processed_scheduled_loadSent when a scheduled load reaches its time and is processed. Subscribe to load.status.
{
"event_type": "processed_scheduled_load",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"load_token": "ld_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"time_of_load": "2026-07-29T15:20:00.000Z",
"client_load_id": "aEjn345",
"source_token": "clnt_wlt_3256e586-cdb1-433e-8390-687ee771d60d",
"wallet_token": "usr_wlt_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"amount": 100.5,
"source_currency_code": "USD",
"notes": "Commission payment for July",
"status": "COMPLETED"
}Notes
statusisCOMPLETEDwhen the load was funded, andCANCELLEDwhen the funding wallet's balance did not cover it.client_load_id,notes, andmetadataare included only when they have a value.- This payload does not carry
user_token. Match onload_token.
wallet.transfer
wallet.transferSent when funds are transferred between two wallets. Subscribe to load.created.
{
"transfer_token": "ld_5d0f6a3b-7c41-4e29-9b8a-1f2e3d4c5b6a",
"source_token": "clnt_wlt_3256e586-cdb1-433e-8390-687ee771d60d",
"destination_token": "usr_wlt_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"source_amount": 250,
"destination_amount": 4637.5,
"source_currency_code": "USD",
"destination_currency_code": "MXN",
"exchange_rate": 18.55,
"fee": 1.25,
"status": "COMPLETED",
"client_transfer_id": "xfer-2026-0001",
"notes": "Monthly allocation",
"metadata": {
"batch": "2026-09"
},
"created_at": "2026-07-29T15:25:43.350112846Z",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "wallet.transfer"
}Notes
exchange_rateis sent only when the two wallets hold different currencies. For a transfer in a single currency it is omitted,feeis0, anddestination_amountequalssource_amount.source_tokenanddestination_tokenare returned exactly as they were sent in the transfer request.client_transfer_id,notes, andmetadataare included only when they were sent in the request.statusis alwaysCOMPLETED.- This payload does not carry
user_token. Identify the wallets fromsource_tokenanddestination_token.
load_reversal
load_reversalSent when a load is cancelled or reversed. Subscribe to load.reversed.
{
"load_token": "ld_ba4275f2-bae1-488d-9d6f-20af1cd83574",
"time_of_reversal": "2026-07-26T22:54:54.793Z",
"user_token": "usr_123e4567-e89b-12d3-a456-426614174000",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "load_reversal"
}spendback
spendbackSent when a spendback is created and on every subsequent status change. Subscribe to spendback.created, spendback.status, or both.
{
"spendback_token": "123e4567-e89b-12d3-a456-426614174000",
"client_spendback_id": "aEjn345",
"status": "success",
"event_type": "spendback",
"metadata": { "property": "value" },
"user_token": "usr_6bee1675-5201-4b19-a44b-5c76b7e70a26",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"amount": 100.5
}inactivity_spendback
inactivity_spendbackSent when an inactivity spendback is applied against a user's wallet. Subscribe to spendback.inactivity.
This event uses the same schema as spendback and differs only in event_type. A single handler can process both — branch on event_type if inactivity charges need different downstream treatment, such as notifying the user or recording the charge as a fee rather than a transfer.
{
"spendback_token": "7c1e9b40-3d52-4a8f-b6d1-9e40f2a7c531",
"event_type": "inactivity_spendback",
"occurred_at": "2026-08-06T16:00:09.778113Z",
"user_token": "usr_6bee1675-5201-4b19-a44b-5c76b7e70a26",
"amount": 5.0,
"client_spendback_id": "inactivity_2026_08",
"status": "success",
"metadata": {
"reason": "account_inactive_12_months",
"last_activity_at": "2025-08-04T09:14:22Z"
}
}Notes
metadatais a free-form object and its contents are not guaranteed. Do not require any particular key.- Precision on
occurred_atvaries between events. Parse it as an RFC 3339 timestamp rather than matching a fixed number of fractional digits.
Users
user_created
user_createdSent when a user is created. Subscribe to user.created.
{
"user_token": "usr_fbe54cb8-91cc-11f1-af32-0613251ca259",
"status": "ACTIVE",
"created_on": "2026-08-06T19:28:21.000Z",
"internal_user_id": "a7f3c2e9-4b81-4d6a-9f52-31c8e0b7d4a6",
"address1": "111 Wall Street",
"address2": "Suite 400",
"city": "New York",
"state_province": "NY",
"postal_code": "10043",
"country": "USA",
"first_name": "Jordan",
"middle_name": "",
"last_name": "Rivera",
"email": "[email protected]",
"language": "en",
"mobile_number": "+12125550142",
"date_of_birth": "1994-07-21",
"activation_url": "https://members.masspay.io/activation?activation=eyJhbGciOiJIUzI1NiJ9.<jwt>",
"metadata": {
"resource_owner_id": "user_9UM4mCcPiRGya"
},
"event_type": "user_created",
"occurred_at": "2026-08-06T19:28:21.215505377Z"
}Notes
- This is the most sensitive payload MassPay sends. It contains the user's name, email, phone number, date of birth, and full address. Exclude it from application logs and error-reporting tools, and make sure your endpoint is not behind a shared or third-party proxy that retains request bodies.
activation_urlcontains a signed, single-use activation token. Anyone who obtains this URL can activate the account. Treat it as a credential: do not log it, do not store it beyond what is needed to deliver it to the user, and deliver it only over a channel the user controls. The token expires; see theexpclaim.middle_nameis an empty string rather thannullwhen not supplied. Do not test for null alone.statusis the user's status at creation, using the same values asuser_status_update.countryis a three-letter ISO 3166-1 alpha-3 code, matching payout and payin events.
user_status_update
user_status_updateSent whenever a user's status changes. Subscribe to user.status. Both the previous and new status are included so you can detect specific transitions.
{
"user_token": "usr_123e4567-e89b-12d3-a456-426614174000",
"new_status": "LOCKED",
"previous_status": "ACTIVE",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "user_status_update"
}Businesses
business_created
business_createdSent when a business is created. Subscribe to business.created.
{
"address1": "6942 Richmond Dr.",
"phone": "12125550142",
"company_name": "Northwind Trading Corp",
"rep_first_name": "Alex",
"rep_last_name": "Chen",
"rep_email": "[email protected]",
"rep_dob": "1988-04-07",
"rep_id": "{\"dob\": \"1988-04-07\", \"name\": \"Alex J Chen\", \"state\": \"\", \"number\": \"000-00-8214\", \"address\": \"\", \"exp_date\": \"2034-08-06\", \"issue_date\": \"2025-08-05\",\"type\":\"SOCIAL_SECURITY\",\"country_code\":\"USA\"}",
"incorporation_date": "2024-05-29",
"registration_number": "00-0000000",
"legal_structure": "Ccorp",
"event_type": "business_created",
"occurred_at": "2026-08-06T01:47:16.465932013Z"
}Notes
- This payload identifies a named individual and includes their date of birth and government identifier. Exclude it from application logs and error-reporting tools.
rep_idis a JSON-encoded string, not an object — note the escaped quotes in the example. Parse it a second time.numberinsiderep_idholds the representative's government-issued identifier in full, of the type given intype. Store it only if you have a business reason to, and encrypt it at rest.stateandaddressinsiderep_idare empty strings, notnull.address2,rep_dob,incorporation_date, andcompany_dbaare included only when they were supplied.phonehas no leading+, unlikemobile_numberonuser_created. Normalize before comparing across events.- The payload carries no business or user token. Correlate on
registration_numberorcompany_name.
business_status_update
business_status_updateSent when the status of a business you created changes — for example, when it completes onboarding and goes live. Subscribe to business.status. Both the previous and new status are included so you can detect specific transitions.
{
"business_token": "5a7c1f0e-2b64-4d8a-9c3e-7f1b6d2a9e40",
"new_status": "Live",
"previous_status": "Onboarding",
"event_type": "business_status_update",
"occurred_at": "2026-09-27T17:42:10.318Z"
}Notes
new_statusandprevious_statuseach take one ofOnboarding,Live,Design Partner,Internal,Deactivated, orDeclined. Every business starts asOnboarding.business_tokenis the token returned when you created the business. Use it to match this event to your record.business_createddoes not carry the token.- The event is sent only when the status actually changes. Saving a business without changing its status sends nothing.
- The event is sent to the account that created the business, not to the business itself.
Attributes
stored_attrs
stored_attrsSent when a set of attributes is stored against a user. Subscribe to attributes.stored.
{
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8",
"attr_set_token": "attr_set_b1a867c1-6e36-4525-b6d5-a20bac80e3b0",
"internal_user_id": "e21b95d7-8c04-4a3f-b7e6-5d92f1a08c34",
"event_type": "stored_attrs",
"occurred_at": "2026-08-06T19:28:21.215505Z"
}Notes
- This is a notification, not the data. It reports that attributes were stored, not what they contain. Fetch the values with Get user attributes using the
attr_set_token. attr_set_tokenis the same identifier that appears onpayout, so you can link a stored attribute set to the payouts that use it.- Precision on
occurred_atvaries between events. Parse it as an RFC 3339 timestamp rather than matching a fixed number of fractional digits.
Verification
id_requested
id_requestedSent when identity verification is requested from a user. Delivered under the kyc.id topic. Surface link to the user so they can complete verification.
{
"time_of_request": 1785338737,
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8",
"event_type": "id_requested",
"link": "https://alchemy.veriff.com/v/eyJhbGciOiJIUzI1NiJ9.<session>",
"occurred_at": "2026-07-29T15:25:43.412Z"
}Notes
time_of_requestis a Unix timestamp in seconds, not a formatted date.linkopens a live verification session for this user. Deliver it only over a channel the user controls, and do not log it. It is omitted when no session could be created.
Provider decisions
When an identity provider returns a result, MassPay forwards it under the kyc.id topic. The payload's event_type names the provider:
These events forward the provider's own payload. The body you receive is whatever that provider sent, with MassPay's event_type and occurred_at added. Which provider applies depends on how your account is configured.
The consequence for integrators: the payload shape is not stable across providers. Field names, nesting, status vocabularies, and how the MassPay user_token is carried all differ. Write your handler against the provider configured for your account, and treat a provider change as a breaking change. If you are unsure which provider your account uses, ask your account representative.
Examples for Veriff and EVS follow. For the other providers, refer to the provider's own documentation for the payload.
veriff
veriffSent when Veriff reports on a verification session. Delivered under the kyc.id topic.
Veriff sends three kinds of payload, and MassPay forwards each of them as event_type: "veriff". Detect the kind from the keys at the top level of the body, then read the decision from the matching field. A handler that expects only one of them will misread the others.
| Top-level key present | Kind | Decision | MassPay user_token |
|---|---|---|---|
verification | Decision, Veriff webhook v1 | verification.status | verification.vendorData |
data (with data.verification) | Decision, Veriff webhook v2 | data.verification.decision | vendorData at the top level |
action | Event (the session was started, submitted, and so on), no decision | — | vendorData at the top level |
Decision values are Veriff's: approved, declined, resubmission_requested, review, expired, and abandoned. Everything other than event_type, occurred_at, and the fields noted below is Veriff's own schema, including its camelCase naming. Which webhook version Veriff sends is set in your Veriff integration at MassPay; a production account can receive a different version than the sandbox did, so handle both decision shapes.
Decision, v1 — the decision is verification.status:
{
"status": "success",
"verification": {
"id": "d73bf06a-1c5b-499c-ab54-5ad680a64543",
"code": 9121,
"person": {
"gender": "M",
"lastName": "MOUSE",
"addresses": [
{
"fullAddress": "1600 PENNSYLVANIA AVENUE NW, WASHINGTON, DC 20500 UNITED STATES",
"parsedAddress": {
"city": "WASHINGTON",
"street": "PENNSYLVANIA AVENUE",
"country": "UNITED STATES",
"postcode": "20500",
"houseNumber": "1600"
}
}
],
"firstName": "MICKEY",
"dateOfBirth": "1998-03-09",
"nationality": "US"
},
"reason": "Attempted deceit, device screen used",
"status": "review",
"comments": [],
"document": {
"type": "ID_CARD",
"number": "123456DISNEY",
"country": "US",
"validFrom": "2021-12-22",
"validUntil": "2031-12-21"
},
"reasonCode": 504,
"vendorData": "usr_123e4567-e89b-12d3-a456-426614174000",
"decisionTime": "2026-07-29T22:12:26.339342Z",
"acceptanceTime": "2026-07-29T22:11:30.149961Z",
"additionalVerifiedData": {}
},
"technicalData": {
"ip": "192.168.1.1"
},
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "veriff"
}Decision, v2 — the decision is data.verification.decision, and the session identifiers and vendorData sit at the top level. Abridged:
{
"status": "success",
"sessionId": "d73bf06a-1c5b-499c-ab54-5ad680a64543",
"attemptId": "8e2f1c9a-0b47-4d1e-9f6a-3c5b7d9e1f20",
"vendorData": "usr_123e4567-e89b-12d3-a456-426614174000",
"endUserId": null,
"time": "2026-10-01T14:02:11.000000Z",
"data": {
"verification": {
"decision": "approved",
"decisionScore": 0.97,
"person": { },
"document": { }
}
},
"occurred_at": "2026-10-01T14:02:12.418339201Z",
"event_type": "veriff"
}Event — no decision yet; action says what happened to the session:
{
"id": "d73bf06a-1c5b-499c-ab54-5ad680a64543",
"attemptId": "8e2f1c9a-0b47-4d1e-9f6a-3c5b7d9e1f20",
"feature": "selfid",
"code": 7002,
"action": "submitted",
"vendorData": "usr_123e4567-e89b-12d3-a456-426614174000",
"occurred_at": "2026-10-01T13:58:40.221067334Z",
"event_type": "veriff"
}Notes on the Veriff shapes
- In the v1 shape there are two
statusfields. The top-level one reports whether the provider's call succeeded; the decision isverification.status. In the v2 shape the top-levelstatusis also only the call result; the decision isdata.verification.decision. - When MassPay records a decision on your behalf — for example, after a document is uploaded through MassPay rather than through Veriff — the decision field is rewritten to the new value in lower case, and MassPay adds
last_modified(v1 and v2) andreason(v1). The shape stays the same. vendorDatacarries the MassPayuser_token. Other providers carry it elsewhere, so do not treatvendorDataas a general lookup path.endUserId,attemptId, andsessionIdare Veriff's identifiers. Match onvendorDataor onverification.id/sessionId, which name the same session.- When the result arrives because you linked a Veriff session, the body is Veriff's decision response as MassPay fetched it, with only
event_typeandoccurred_atadded.
evs
evsSent when EVS returns the result of an identity check. Delivered under the kyc.id topic.
{
"meta": {
"transactionId": "0b6f4c1e-52d9-4a7b-8e33-9c1d7f20a6b4",
"customerReference": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8"
},
"data": {
"workflowOutcome": {
"code": "P"
},
"assureCard": {
"documentName": {
"firstName": "MICKEY",
"middleName": "T",
"familyName": "MOUSE"
},
"individualCharacteristics": {
"dateOfBirth": "1998-03-09"
},
"documentAddress": {
"address": "1600 PENNSYLVANIA AVENUE NW",
"city": "WASHINGTON",
"state": "DC",
"zipCode": "20500"
},
"documentInformation": {
"expirationDate": "2031-12-21"
}
}
},
"event_type": "evs",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8"
}Notes on the EVS shape specifically
data.workflowOutcome.codeis the decision:Pis approved andRis sent to review. Treat any other value as declined.user_tokenis added by MassPay when EVS reports the result. The same value is inmeta.customerReference. When the result is forwarded because you linked an EVS session,user_tokenis not added, so readmeta.customerReference.- One check can produce more than one event, for the result, for an error, and for the document images. Group them on
meta.transactionId. - The payload can contain the document images as base64 strings. It is significantly larger than other webhooks, so size your request body limits accordingly.
- This payload contains identity document data. Exclude it from application logs and error-reporting tools.
- Everything under
metaanddatais EVS's schema, including its camelCase naming.
avs.completed
avs.completedSent when account ownership verification completes. Subscribe to avs.completed.
{
"event_type": "avs.completed",
"user_token": "usr_1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"country_code": "BR",
"status": "CLOSE_MATCH",
"ownership_verified": true,
"occurred_at": "2026-08-06T18:22:41.371Z",
"match_score": 83
}Note:
country_codeis a two-letter ISO 3166-1 alpha-2 code on this event, unlike the three-letter codes used on payout and payin events.
Documents
The four file upload events share the same base shape and differ in event_type; the rejection event adds the fields described under file_upload_req_rejected. Three topics deliver them, so subscribe to the ones you need:
| Topic | Payload event_type |
|---|---|
file_upload_req.created | file_upload_req_created |
file_upload_req.status | file_upload_req_uploaded, file_upload_req_rejected |
file_upload_req.verified | file_upload_req_verified |
| Field | Description |
|---|---|
file_upload_token | Identifies the upload request across all four events. |
files_types_accepted | Document types the user may upload. |
is_required | Whether the upload blocks the user's progress. |
link | The URL to surface to the user. Present on creation. |
ref_attributes | Masked reference values to help the user identify what is being verified. |
note | Optional instructions to display to the user. |
file_upload_req_created
file_upload_req_createdSent when a file upload link is created. Subscribe to file_upload_req.created.
{
"file_upload_token": "file_req_501199fc-b914-4a45-b5d4-6273ec62b31f",
"files_types_accepted": [
"Bank Statement",
"Physical Check"
],
"time_of_request": "2026-07-29 14:32:17",
"is_required": true,
"link": "https://l.maspay.io/abc123",
"event_type": "file_upload_req_created",
"ref_attributes": {
"BankAccountNumber": "**********6789",
"Routing Number": "**********4321"
},
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"note": "Please include all pages of the bank statement",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8"
}file_upload_req_uploaded
file_upload_req_uploadedSent when the user uploads a file against an existing link. Subscribe to file_upload_req.status. The same topic also delivers file_upload_req_rejected, so branch on event_type.
{
"file_upload_token": "file_req_501199fc-b914-4a45-b5d4-6273ec62b31f",
"files_types_accepted": [
"Government issued ID"
],
"time_of_request": "2026-07-29 15:32:17",
"is_required": false,
"ref_attributes": {},
"event_type": "file_upload_req_uploaded",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8"
}file_upload_req_rejected
file_upload_req_rejectedSent when the files uploaded against a link are reviewed and rejected. Subscribe to file_upload_req.status. A replacement upload request for the same requirement is created at the same time, and that replacement is announced separately with its own file_upload_req_created event.
{
"file_upload_token": "file_req_501199fc-b914-4a45-b5d4-6273ec62b31f",
"files_types_accepted": [
"Bank Statement"
],
"time_of_request": "2026-07-29 14:32:17",
"is_required": true,
"is_verified": false,
"is_rejected": true,
"ref_attributes": {
"Account Number": "**********6789"
},
"time_rejected": "2026-07-31 09:12:45",
"rejection_reason": "Statement is older than 90 days",
"replacement_file_upload_token": "file_req_7a3e9c21-0f4b-4d8e-9b57-2c6d1e8f0a93",
"replacement_file_upload_link": "https://l.maspay.io/def456",
"event_type": "file_upload_req_rejected",
"occurred_at": "2026-07-31T09:12:45.618204117Z",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8"
}Fields added on this event
| Field | Description |
|---|---|
is_rejected | Always true on this event. It is the counterpart of is_required, and the file upload request endpoints return it as well. |
is_verified | Whether the request had been verified before it was rejected. A rejection stands regardless of this value. |
time_rejected | When the files were rejected, in the same format as time_of_request. |
rejection_reason | The reason given by the reviewer. Omitted when none was given. Suitable for display to the user; do not branch on its text. |
replacement_file_upload_token | The upload request opened for the same requirement. Omitted when no replacement could be opened. |
replacement_file_upload_link | The URL to surface to the user for the replacement request. Present whenever replacement_file_upload_token is. |
Notes
- The rejected request no longer counts toward anything. It is ignored by every requirement check, it no longer blocks the user's payouts, and further uploads against its link are refused. Direct the user to
replacement_file_upload_linkinstead. - The replacement request carries the same
files_types_accepted,is_required,ref_attributes, andnoteas the rejected one, under a newfile_upload_token. Itsfile_upload_req_createdevent can arrive before or after this one; see Handle out-of-order events. time_of_requestis the time the rejected request was originally created, not the time of the rejection.- Get file upload requests by user token and the single-request endpoint report the rejected request with
statusrejected,is_rejectedtrue,time_rejected,rejection_reason, andreplacement_file_upload_token, so you can reconcile after an outage. time_rejectedis written without a time zone suffix, liketime_of_request.
file_upload_req_verified
file_upload_req_verifiedSent when an uploaded file has been reviewed and verified. Subscribe to file_upload_req.verified.
{
"file_upload_token": "file_req_501199fc-b914-4a45-b5d4-6273ec62b31f",
"files_types_accepted": [
"Bank Statement"
],
"time_of_request": "2026-07-29 16:32:17",
"is_required": true,
"ref_attributes": {},
"event_type": "file_upload_req_verified",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"user_token": "usr_685a6135-b7c0-44aa-9e10-dc403acb2cc8"
}Tax
All tax events are delivered under the kyc.tax topic.
tax_form
tax_formSent when a tax form has been generated for a user. Delivered under the kyc.tax topic. pdf_content contains the form, and one event is sent for each file.
{
"user_token": "usr_c06de89a-b6cb-4c75-8707-10ba8c5b69e4",
"pdf_content": "JVBERi0xLjcK",
"file_name": "Form1099NEC_Recipient.pdf",
"event_type": "tax_form",
"occurred_at": "2026-07-29T15:25:43.412751752Z"
}Notes
pdf_contentis the file, base64-encoded.file_nameis its original name.- A form made up of several files arrives as several events. Group them on
user_tokenand arrival time.
Handle with care. This payload contains a tax document. It is significantly larger than other webhooks — size your request body limits accordingly — and it should be excluded from application logs and error-reporting tools.
tax_wh_interview
tax_wh_interviewSent when a user completes a tax interview. Delivered under the kyc.tax topic. pdf_base64 contains the completed form.
{
"user_token": "usr_c06de89a-b6cb-4c75-8707-10ba8c5b69e4",
"pdf_base64": "H4sAAAA",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "tax_wh_interview"
}Handle with care. This payload contains a tax document. It is significantly larger than other webhooks — size your request body limits accordingly — and it should be excluded from application logs and error-reporting tools.
tax_tin_matching
tax_tin_matchingSent when TIN matching returns a result. Delivered under the kyc.tax topic. tin is masked.
{
"user_token": "usr_da518958-1413-11f0-9de5-06cab49e9a05",
"name": "MICKEY MOUSE",
"tin_type": "SSN",
"tin": "XXX-XX-0657",
"status": "SUCCESS",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "tax_tin_matching"
}tax_identity
tax_identitySent when a tax identity interview returns a result. Delivered under the kyc.tax topic.
This event forwards the tax identity provider's own payload, with MassPay's event_type and occurred_at added. Field names follow the provider's PascalCase naming, which differs from the snake_case used everywhere else in this reference.
{
"IdentityKey": "0d5c1b7e-94a2-4f6b-8c13-2e7a9f40b6d1",
"AccountId": "usr_c06de89a-b6cb-4c75-8707-10ba8c5b69e4",
"LegalName": "Mickey Mouse",
"TIN": "000000657",
"TINType": "SSN",
"TaxStatus": "ValidatedTaxInfo",
"FormType": "W9",
"IsUSPerson": true,
"IsIndividual": true,
"FirstName": "Mickey",
"LastName": "Mouse",
"EmailAddress": "[email protected]",
"DateOfBirth": "1998-03-09T00:00:00Z",
"PermAddress": {
"AddressLine1": "1600 Pennsylvania Avenue NW",
"AddressCity": "Washington",
"AddressState": "DC",
"AddressPostcode": "20500",
"AddressCountryCode": "US"
},
"event_type": "tax_identity",
"occurred_at": "2026-07-29T15:25:43.412751752Z"
}Notes
- There is no
user_tokenfield.AccountIdcarries the MassPayuser_token. TaxStatusis the outcome. It is one ofMissingTaxInfo,TaxInfoRequiresUpdate,TaxInfoOnFile,ValidatedTaxInfo,InvalidTaxInfo, orDeactivated.- Any other field is included only when the provider returned it. Do not require a particular key.
- Characters outside ASCII are replaced with spaces, so accented names arrive altered. Do not use this payload as the source of a user's name.
Handle with care.
TINis sent in full, not masked, along with the user's date of birth, email address, and postal address. Exclude this payload from application logs and error-reporting tools.
Account
new_deposit_account
new_deposit_accountSent when a deposit account is issued for funding your balance. Subscribe to deposit_account.created.
{
"account_number": "12345678",
"routing_number": "012345678",
"institution": "CRB",
"topup_token": "topup_68f4fdfb-6586-441a-8cbb-9f9e352c0b1e",
"funding_token": "fund_9767edf2-04e4-4a4a-9d8c-afe5fa5f2ba8",
"title": "Company ABC",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "new_deposit_account"
}balance_credit
balance_creditSent when your account balance is credited. Subscribe to balance.credit.
{
"amount": 15.1,
"currency": "USD",
"source": "Wells Fargo",
"occurred_at": "2026-07-29T15:25:43.412751752Z",
"event_type": "balance_credit"
}Troubleshooting
| Symptom | Likely cause |
|---|---|
| Signature never matches | The body is being re-serialized before verification. Use the raw bytes. |
| Signature matches locally, fails in production | Load balancer or middleware is rewriting the body, or a different secret is configured per environment. |
| Requests rejected as stale | t is being read as seconds — it is in milliseconds. Otherwise, server clock drift: sync via NTP. |
| Duplicate records created | Handler is not idempotent, or is responding after the timeout and triggering retries. |
The same payout.status or payin.status event keeps arriving | Your handler reads the object with force_status_update=true, which sends the webhook again on every call. Read it without the flag. |
| Deliveries succeed in sandbox, fail in production | The handler takes longer than 3 seconds to respond. Sandbox allows 30. See Request timeout. |
| No requests reach your endpoint | A firewall or WAF is blocking MassPay. Allow the source IP addresses. |
| Status appears to move backwards | Out-of-order delivery. Compare occurred_at before applying. |
| Events are missing after an outage | Every attempt failed and the events were dropped. Reconcile through the API — see After the final attempt fails. |
| Events stop arriving | The webhook was deleted, or it was disabled because its hostname could not be resolved in DNS. Check GET /payout/account/webhooks; if the webhook is missing, create a new one. See Automatic disabling. |
| One event type never arrives | The webhook is not subscribed to its topic. Check event_types via GET /payout/account/webhooks. |
Subscribed topic doesn't match the event_type received | Expected. Topics and payload event types are separate vocabularies — see Subscription topics. |
webhook_secret was lost | It is only returned at creation. Delete the webhook and create a replacement. |