Webhooks

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

  1. Getting started
  2. Registering and managing webhooks
  3. Verifying signatures
  4. Delivery, retries, and idempotency
  5. Event reference

Getting started

1. Build an endpoint

Your endpoint must:

  • Be reachable over HTTPS on the public internet (no self-signed certificates).
  • Accept POST requests with a Content-Type: application/json body.
  • Return a 2xx status 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

ActionMethod and pathReference
Create a webhookPOST /payout/account/webhooksCreate a webhook configuration
List webhooksGET /payout/account/webhooksGet webhooks
Delete a webhookDELETE /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

FieldTypeRequiredDescription
webhook_urlstringYesThe HTTPS URL that will receive the payloads.
event_typesarray of stringsYesThe 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"
}
FieldTypeDescription
webhook_tokenstringIdentifies the webhook configuration. Use it to delete the webhook.
webhook_secretstringThe 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_secret now. It is returned only on creation and is not available from GET /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:

  1. Create a new webhook with the desired configuration.
  2. Deploy your endpoint so it accepts both the old and new secrets.
  3. Confirm events are arriving on the new configuration.
  4. 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
ElementDescription
tUnix timestamp in milliseconds at which the signature was generated.
sLowercase 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 or request.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

BehaviorProductionSandbox
Success responseAny 2xxAny 2xx
Request timeout3 seconds30 seconds
Delivery attemptsUp to 6 — the first attempt plus 5 retriesSame
Retry scheduleOne attempt about every hour; the last is about 5 hours after the firstSame
After final failureThe event is dropped. The webhook stays active, and no failure notice is sent.Same
Delivery guaranteeBest effort, with retries. An event can arrive more than once; an event whose attempts all fail is not delivered.Same
OrderingNot guaranteedNot guaranteed
Source IPs54.185.181.9434.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.

AttemptSentRandom delay added
1Within seconds of the event—
2About 1 hour after attempt 1Up to 4 seconds
3About 2 hours after attempt 1Up to 8 seconds
4About 3 hours after attempt 1Up to 16 seconds
5About 4 hours after attempt 1Up to 32 seconds
6 (final)About 5 hours after attempt 1Up 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, so t and s change.
  • 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:

  1. Restore your endpoint so that it returns 2xx within the timeout.
  2. Confirm the webhook is still registered with GET /payout/account/webhooks. If it is missing, it was disabled — create a new one.
  3. 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:

EnvironmentIP addressCIDR
Production (api.masspay.io)54.185.181.9454.185.181.94/32
Sandbox (staging-api.masspay.io)34.223.172.2934.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:

FieldTypeDescription
event_typestringIdentifies which event fired. Switch on this value.
occurred_atstringRFC 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

TopicDeliversPayload event_type
payout.createdA payout is createdpayout
payout.statusA payout changes statuspayout
payout_reversal.createdA reversal is created against a payoutpayout_reversal_created
payout_reversal.statusA payout reversal changes statuspayout_reversal_status, payout_reversal_update
payin.createdA payin is createdpayin_status
payin.statusA payin changes statuspayin_status
payin_deposit.returnedA return is initiated for a deposit against a payinpayin_deposit.returned
payin_deposit.return_completedA deposit against a payin is returnedpayin_deposit_return_completed
load.createdA load is created, or funds are transferred between walletsload, wallet.transfer
load.statusA load completes, or a scheduled load is processedload.status, processed_scheduled_load
load.reversedA load is cancelled or reversedload_reversal
spendback.createdA spendback is createdspendback
spendback.statusA spendback changes statusspendback
spendback.inactivityAn inactivity spendback is appliedinactivity_spendback
user.createdA user is createduser_created
user.statusA user's status changesuser_status_update
business.createdA business is createdbusiness_created
business.statusA business's status changesbusiness_status_update
kyc.idIdentity verification activity, including forwarded ID provider payloadsid_requested, plus one per identity provider: veriff, evs, persona, sumsub, ondato, shuftipro, stripe, aiprise, yoti, plaid, didit
kyc.taxTax forms, tax interviews, and TIN matching activitytax_form, tax_wh_interview, tax_tin_matching, tax_identity
file_upload_req.createdA file upload link is createdfile_upload_req_created
file_upload_req.statusA file is uploaded against a link, or uploaded files are rejectedfile_upload_req_uploaded, file_upload_req_rejected
file_upload_req.verifiedAn uploaded file is verifiedfile_upload_req_verified
avs.completedAccount ownership verification completesavs.completed
attributes.storedUser attributes are storedstored_attrs
deposit_account.createdA deposit account is issuednew_deposit_account
balance.creditYour account balance is creditedbalance_credit

Payload event_type values

This is what arrives in the body. Jump to any event for its full payload.

event_typeCategoryFires when
payoutPayoutsA payout transaction is created or changes status
payout_reversal_createdPayoutsA reversal is created against a payout
payout_reversal_statusPayoutsA payout reversal changes status
payout_reversal_updatePayoutsA payout reversal is updated by the banking network
payin_statusPayinsA payin is created or changes status
payin_deposit.returnedPayin depositsA return is initiated for a deposit against a payin
payin_deposit_return_completedPayin depositsA deposit against a payin is returned
loadLoadsA load is created
load.statusLoadsA load completes
processed_scheduled_loadLoadsA scheduled load is processed
load_reversalLoadsA load is cancelled or reversed
wallet.transferLoadsFunds are transferred between wallets
spendbackLoadsA spendback is created or changes status
inactivity_spendbackLoadsAn inactivity spendback is applied
user_createdUsersA user is created
user_status_updateUsersA user's status changes
business_createdBusinessesA business is created
business_status_updateBusinessesA business's status changes
stored_attrsAttributesUser attributes are stored
id_requestedVerificationID verification is requested from a user
veriffVerificationVeriff returns a verification decision
evsVerificationEVS returns the result of an identity check
persona, sumsub, ondato, shuftipro, stripe, aiprise, yoti, plaid, diditVerificationThe named identity provider returns a result
avs.completedVerificationAccount ownership verification completes
file_upload_req_createdDocumentsA file upload link is created
file_upload_req_uploadedDocumentsA file is uploaded against a link
file_upload_req_rejectedDocumentsUploaded files are rejected and a replacement link is created
file_upload_req_verifiedDocumentsAn uploaded file is verified
tax_formTaxA tax form is submitted
tax_wh_interviewTaxA tax interview is completed
tax_tin_matchingTaxTIN matching returns a result
tax_identityTaxA tax identity interview returns a result
new_deposit_accountAccountA deposit account is issued
balance_creditAccountYour account balance is credited

Payouts

payout

Sent 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_amount is source_amount × exchange_rate. fee is charged in the source currency and is not deducted from destination_amount.
  • Use client_transfer_id to reconcile against your own records.
  • user_token is sent on status changes. The creation event does not carry it.
  • source_token can arrive with or without its wallet prefix (clnt_wlt_, usr_wlt_, or mp_card_). Compare on the UUID.
  • reversible_amount is the part of the payout that can still be reversed.
  • client_transfer_id, pickup_code, trace_code, payer_name, and metadata are included only when they have a value.
  • attrs is always an empty object on this event. Fetch the values with Get user attributes using the attr_set_token.
  • The reason for a failed or held payout is not part of this event. Read status_reason from 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 status changed. Apply an event only when status differs from what you have stored — see Expect duplicates.

payout_reversal_created

Sent 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_amount is the full amount of the original payout; destination_amount is the amount of this reversal.
  • total_reversed_amount is 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

Sent 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_token to update the record created by payout_reversal_created.
  • This event carries user_token, created, and completed, which the creation event does not.
  • status is always COMPLETED on this event.
  • created and completed are 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

Sent 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

  • status is READY_FOR_PICKUP when the debit has been sent to the banking network, and CANCELLED when the network returned or cancelled it.
  • status_reason is 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_id and metadata are included only when they have a value.
  • This event has no created or completed. Those arrive on payout_reversal_status.

Payins

payin_status

Sent 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_amount is the expected amount; source_amount_collected is 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. deposits is an empty array until the first deposit arrives.
  • The creation event and the status events share this payload. On creation, status is the status the payin was created in.
  • deposits[].metadata is a JSON-encoded string, not an object. Parse it a second time, and guard against it being absent.
  • deposits[].metadata can 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, and metadata are 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:

StepTopicPayload event_type
The return is initiatedpayin_deposit.returnedpayin_deposit.returned
The banking network settles the returnpayin_deposit.return_completedpayin_deposit_return_completed

payin_deposit.returned

Sent 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

FieldDescription
return_reason_codeMassPay'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_codeThe 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_reasonHuman-readable explanation. Suitable for display or support tooling, but do not branch on its text. Omitted when none was given.
return_feeFee charged for processing the return, separate from the original fee.
fee_payerWho bears return_fee: CLIENT or DEPOSITOR.
returned_atWhen the return was initiated.

Notes

  • token is the deposit token. It matches an entry in the deposits array of the corresponding payin_status event — use it to locate the record to reverse, and payin_token to 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_at remains the original completion time. returned_at and updated_at are when the return was initiated.
  • returned_at, created_at, completed_at, and updated_at are written without a time zone suffix.
  • external_id and transaction_reference are included only when they have a value.
  • This event carries no metadata and no trace_code. Both arrive on payin_deposit_return_completed, which repeats the return fields.

payin_deposit_return_completed

Sent 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 token to the payin_deposit.returned event you received when the return was initiated.
  • The return details — return_reason_code, network_return_code, return_reason, return_fee, fee_payer, and returned_at — are described under Return fields. They are included when MassPay has them. Do not require them on this event.
  • returned_at is when the return was initiated. occurred_at is when the banking network settled it.
  • trace_code is the network trace identifier for the return, distinct from transaction_reference on 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_id and transaction_reference are included only when they have a value.
  • Timestamps other than occurred_at are written without a time zone suffix.

metadata is 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:

WhenTopicPayload event_type
The load is createdload.createdload
The load completes as it is createdload.statusload.status
A scheduled load reaches its time and is processedload.statusprocessed_scheduled_load

load

Sent 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

  • status is COMPLETED for a load that runs immediately and SCHEDULED for one with a future time_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 using user_token.
  • metadata is included only when the load was created with it.

load.status

Sent 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

processed_scheduled_load

Sent 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

  • status is COMPLETED when the load was funded, and CANCELLED when the funding wallet's balance did not cover it.
  • client_load_id, notes, and metadata are included only when they have a value.
  • This payload does not carry user_token. Match on load_token.

wallet.transfer

Sent 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_rate is sent only when the two wallets hold different currencies. For a transfer in a single currency it is omitted, fee is 0, and destination_amount equals source_amount.
  • source_token and destination_token are returned exactly as they were sent in the transfer request.
  • client_transfer_id, notes, and metadata are included only when they were sent in the request.
  • status is always COMPLETED.
  • This payload does not carry user_token. Identify the wallets from source_token and destination_token.

load_reversal

Sent 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

Sent 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

Sent 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

  • metadata is a free-form object and its contents are not guaranteed. Do not require any particular key.
  • Precision on occurred_at varies between events. Parse it as an RFC 3339 timestamp rather than matching a fixed number of fractional digits.

Users

user_created

Sent 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_url contains 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 the exp claim.
  • middle_name is an empty string rather than null when not supplied. Do not test for null alone.
  • status is the user's status at creation, using the same values as user_status_update.
  • country is a three-letter ISO 3166-1 alpha-3 code, matching payout and payin events.

user_status_update

Sent 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

Sent 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_id is a JSON-encoded string, not an object — note the escaped quotes in the example. Parse it a second time.
  • number inside rep_id holds the representative's government-issued identifier in full, of the type given in type. Store it only if you have a business reason to, and encrypt it at rest.
  • state and address inside rep_id are empty strings, not null.
  • address2, rep_dob, incorporation_date, and company_dba are included only when they were supplied.
  • phone has no leading +, unlike mobile_number on user_created. Normalize before comparing across events.
  • The payload carries no business or user token. Correlate on registration_number or company_name.

business_status_update

Sent 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_status and previous_status each take one of Onboarding, Live, Design Partner, Internal, Deactivated, or Declined. Every business starts as Onboarding.
  • business_token is the token returned when you created the business. Use it to match this event to your record. business_created does 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

Sent 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_token is the same identifier that appears on payout, so you can link a stored attribute set to the payouts that use it.
  • Precision on occurred_at varies between events. Parse it as an RFC 3339 timestamp rather than matching a fixed number of fractional digits.

Verification

id_requested

Sent 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_request is a Unix timestamp in seconds, not a formatted date.
  • link opens 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:

event_typeProvider
veriffVeriff
evsEVS
personaPersona
sumsubSumSub
ondatoOndato
shuftiproShuftiPro
stripeStripe Identity
aipriseAiPrise
yotiYoti
plaidPlaid
diditDidit

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

Sent 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 presentKindDecisionMassPay user_token
verificationDecision, Veriff webhook v1verification.statusverification.vendorData
data (with data.verification)Decision, Veriff webhook v2data.verification.decisionvendorData at the top level
actionEvent (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 status fields. The top-level one reports whether the provider's call succeeded; the decision is verification.status. In the v2 shape the top-level status is also only the call result; the decision is data.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) and reason (v1). The shape stays the same.
  • vendorData carries the MassPay user_token. Other providers carry it elsewhere, so do not treat vendorData as a general lookup path.
  • endUserId, attemptId, and sessionId are Veriff's identifiers. Match on vendorData or on verification.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_type and occurred_at added.

evs

Sent 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.code is the decision: P is approved and R is sent to review. Treat any other value as declined.
  • user_token is added by MassPay when EVS reports the result. The same value is in meta.customerReference. When the result is forwarded because you linked an EVS session, user_token is not added, so read meta.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 meta and data is EVS's schema, including its camelCase naming.

avs.completed

Sent 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_code is 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:

TopicPayload event_type
file_upload_req.createdfile_upload_req_created
file_upload_req.statusfile_upload_req_uploaded, file_upload_req_rejected
file_upload_req.verifiedfile_upload_req_verified
FieldDescription
file_upload_tokenIdentifies the upload request across all four events.
files_types_acceptedDocument types the user may upload.
is_requiredWhether the upload blocks the user's progress.
linkThe URL to surface to the user. Present on creation.
ref_attributesMasked reference values to help the user identify what is being verified.
noteOptional instructions to display to the user.

file_upload_req_created

Sent 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

Sent 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

Sent 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

FieldDescription
is_rejectedAlways true on this event. It is the counterpart of is_required, and the file upload request endpoints return it as well.
is_verifiedWhether the request had been verified before it was rejected. A rejection stands regardless of this value.
time_rejectedWhen the files were rejected, in the same format as time_of_request.
rejection_reasonThe 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_tokenThe upload request opened for the same requirement. Omitted when no replacement could be opened.
replacement_file_upload_linkThe 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_link instead.
  • The replacement request carries the same files_types_accepted, is_required, ref_attributes, and note as the rejected one, under a new file_upload_token. Its file_upload_req_created event can arrive before or after this one; see Handle out-of-order events.
  • time_of_request is 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 status rejected, is_rejected true, time_rejected, rejection_reason, and replacement_file_upload_token, so you can reconcile after an outage.
  • time_rejected is written without a time zone suffix, like time_of_request.

file_upload_req_verified

Sent 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

Sent 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_content is the file, base64-encoded. file_name is its original name.
  • A form made up of several files arrives as several events. Group them on user_token and 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

Sent 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

Sent 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

Sent 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_token field. AccountId carries the MassPay user_token.
  • TaxStatus is the outcome. It is one of MissingTaxInfo, TaxInfoRequiresUpdate, TaxInfoOnFile, ValidatedTaxInfo, InvalidTaxInfo, or Deactivated.
  • 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. TIN is 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

Sent 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

Sent 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

SymptomLikely cause
Signature never matchesThe body is being re-serialized before verification. Use the raw bytes.
Signature matches locally, fails in productionLoad balancer or middleware is rewriting the body, or a different secret is configured per environment.
Requests rejected as stalet is being read as seconds — it is in milliseconds. Otherwise, server clock drift: sync via NTP.
Duplicate records createdHandler is not idempotent, or is responding after the timeout and triggering retries.
The same payout.status or payin.status event keeps arrivingYour 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 productionThe handler takes longer than 3 seconds to respond. Sandbox allows 30. See Request timeout.
No requests reach your endpointA firewall or WAF is blocking MassPay. Allow the source IP addresses.
Status appears to move backwardsOut-of-order delivery. Compare occurred_at before applying.
Events are missing after an outageEvery attempt failed and the events were dropped. Reconcile through the API — see After the final attempt fails.
Events stop arrivingThe 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 arrivesThe webhook is not subscribed to its topic. Check event_types via GET /payout/account/webhooks.
Subscribed topic doesn't match the event_type receivedExpected. Topics and payload event types are separate vocabularies — see Subscription topics.
webhook_secret was lostIt is only returned at creation. Delete the webhook and create a replacement.