> ## Documentation Index
> Fetch the complete documentation index at: https://docs.leapfinancial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Events

> Reference for all webhook event types and their payloads

## Event types

M2M sends webhooks for the following events:

| Event                 | Description                                     |
| --------------------- | ----------------------------------------------- |
| `link.created`        | A magic link was created                        |
| `link.opened`         | User opened a magic link                        |
| `user.data_request`   | M2M needs additional user data for verification |
| `operation.created`   | A money transfer operation was created          |
| `operation.cancelled` | A money transfer operation was cancelled        |

## link.created

Sent when a magic link is created via the API. Use this to confirm link creation asynchronously and track the beginning of the link lifecycle.

### Payload

```json theme={null}
{
  "event": "link.created",
  "timestamp": "2026-01-26T14:30:00.000Z",
  "data": {
    "linkId": "link_acme_a1b2c3d4e5f6",
    "referenceId": "user_12345",
    "url": "https://send.m2m.leapfinancial.com/link_acme_a1b2c3d4e5f6",
    "status": "created",
    "expiresAt": "2026-01-27T14:30:00.000Z",
    "createdAt": "2026-01-26T14:30:00.000Z"
  }
}
```

<ResponseField name="event" type="string" required>
  Always `link.created`.
</ResponseField>

<ResponseField name="timestamp" type="string" required>
  ISO 8601 timestamp when the webhook was generated.
</ResponseField>

<ResponseField name="data" type="object" required>
  Event payload.

  <Expandable title="data properties">
    <ResponseField name="linkId" type="string" required>
      The magic link identifier.
    </ResponseField>

    <ResponseField name="referenceId" type="string" required>
      Your user identifier.
    </ResponseField>

    <ResponseField name="url" type="string" required>
      The full magic link URL to share with the user.
    </ResponseField>

    <ResponseField name="status" type="string" required>
      Link status. Always `created` for this event.
    </ResponseField>

    <ResponseField name="expiresAt" type="string" required>
      ISO 8601 timestamp when the magic link expires.
    </ResponseField>

    <ResponseField name="createdAt" type="string" required>
      ISO 8601 timestamp when the link was created.
    </ResponseField>
  </Expandable>
</ResponseField>

### Example handler

```javascript Node.js theme={null}
app.post('/webhooks/m2m', async (req, res) => {
  const { event, data } = req.body;
  
  if (event === 'link.created') {
    console.log(`Link ${data.linkId} created for user ${data.referenceId}`);
    
    // Store the link in your system
    await db.links.create({
      linkId: data.linkId,
      referenceId: data.referenceId,
      url: data.url,
      expiresAt: data.expiresAt,
      createdAt: data.createdAt
    });
  }
  
  res.status(200).send('OK');
});
```

***

## link.opened

Sent when a user opens a magic link for the first time. Use this to track engagement and prepare for potential data requests.

### Payload

```json theme={null}
{
  "event": "link.opened",
  "timestamp": "2026-01-26T14:30:00.000Z",
  "data": {
    "linkId": "link_acme_a1b2c3d4e5f6",
    "referenceId": "user_12345",
    "openedAt": "2026-01-26T14:30:00.000Z",
    "deviceInfo": {
      "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...",
      "platform": "iOS",
      "language": "en-US"
    }
  }
}
```

<ResponseField name="event" type="string" required>
  Always `link.opened`.
</ResponseField>

<ResponseField name="timestamp" type="string" required>
  ISO 8601 timestamp when the webhook was generated.
</ResponseField>

<ResponseField name="data" type="object" required>
  Event payload.

  <Expandable title="data properties">
    <ResponseField name="linkId" type="string" required>
      The magic link identifier.
    </ResponseField>

    <ResponseField name="referenceId" type="string" required>
      Your user identifier.
    </ResponseField>

    <ResponseField name="openedAt" type="string" required>
      ISO 8601 timestamp when the user opened the link.
    </ResponseField>

    <ResponseField name="deviceInfo" type="object">
      Information about the user's device.

      <Expandable title="deviceInfo properties">
        <ResponseField name="userAgent" type="string">
          Browser user agent string.
        </ResponseField>

        <ResponseField name="platform" type="string">
          Detected platform (iOS, Android, Windows, macOS, Linux, etc.).
        </ResponseField>

        <ResponseField name="language" type="string">
          Browser language preference.
        </ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

### Example handler

```javascript Node.js theme={null}
app.post('/webhooks/m2m', async (req, res) => {
  const { event, data } = req.body;
  
  if (event === 'link.opened') {
    console.log(`User ${data.referenceId} opened link ${data.linkId}`);
    
    // Update your records
    await db.users.update({
      where: { referenceId: data.referenceId },
      data: { lastLinkOpenedAt: data.openedAt }
    });
    
    // Prepare for potential data request
    await prepareUserDataForM2M(data.referenceId);
  }
  
  res.status(200).send('OK');
});
```

***

## user.data\_request

Sent when M2M needs additional user data to complete identity verification (CIP). This happens when the user data provided during link creation is incomplete.

<Info>
  This webhook gives you an opportunity to provide user data before M2M asks the user directly. Responding quickly improves user experience.
</Info>

### Payload

```json theme={null}
{
  "event": "user.data_request",
  "timestamp": "2026-01-26T14:30:00.000Z",
  "data": {
    "dataRequestId": "dr_acme_x9y8z7w6v5u4",
    "referenceId": "user_12345",
    "linkId": "link_acme_a1b2c3d4e5f6",
    "requiredFields": [
      "name.secondName",
      "name.secondLastName",
      "idNumber",
      "idType",
      "dob"
    ],
    "replyEndpoint": "https://m2m-backend-qa.up.railway.app/partner/data-requests/dr_acme_x9y8z7w6v5u4",
    "expiresAt": "2026-01-26T15:30:00.000Z"
  }
}
```

<ResponseField name="event" type="string" required>
  Always `user.data_request`.
</ResponseField>

<ResponseField name="timestamp" type="string" required>
  ISO 8601 timestamp when the webhook was generated.
</ResponseField>

<ResponseField name="data" type="object" required>
  Event payload.

  <Expandable title="data properties">
    <ResponseField name="dataRequestId" type="string" required>
      Unique identifier for this data request. Use this when responding.
    </ResponseField>

    <ResponseField name="referenceId" type="string" required>
      Your user identifier.
    </ResponseField>

    <ResponseField name="linkId" type="string" required>
      The magic link identifier.
    </ResponseField>

    <ResponseField name="requiredFields" type="array" required>
      List of fields M2M needs. Possible values:

      * `name.firstName`
      * `name.secondName`
      * `name.lastName`
      * `name.secondLastName`
      * `idNumber`
      * `idType`
      * `dob`
    </ResponseField>

    <ResponseField name="replyEndpoint" type="string" required>
      Full URL to send the user data to. Make a PUT request to this URL.
    </ResponseField>

    <ResponseField name="expiresAt" type="string" required>
      ISO 8601 timestamp when this data request expires. Same as link expiration.
    </ResponseField>
  </Expandable>
</ResponseField>

### Responding to data requests

When you receive this webhook, you can provide the missing data by making a PUT request to the `replyEndpoint`:

```bash cURL theme={null}
curl -X PUT https://m2m-backend-qa.up.railway.app/partner/data-requests/dr_acme_x9y8z7w6v5u4 \
  -H "X-API-Key: m2m_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "referenceId": "user_12345",
    "userData": {
      "name": {
        "secondName": "Carlos",
        "secondLastName": "Lopez"
      },
      "idNumber": "GALO900515HDFRPN09",
      "idType": "CURP",
      "dob": "1990-05-15"
    }
  }'
```

<Note>
  You only need to provide the fields listed in `requiredFields`. Any additional fields you provide will be stored but aren't required.
</Note>

### Response to your PUT request

```json theme={null}
{
  "success": true,
  "data": {
    "dataRequestId": "dr_acme_x9y8z7w6v5u4",
    "status": "fulfilled",
    "linkId": "link_acme_a1b2c3d4e5f6",
    "fulfilledAt": "2026-01-26T14:35:00.000Z",
    "message": "User data received and merged successfully"
  }
}
```

### Example handler

```javascript Node.js theme={null}
app.post('/webhooks/m2m', async (req, res) => {
  const { event, data } = req.body;
  
  if (event === 'user.data_request') {
    // Acknowledge immediately
    res.status(200).send('OK');
    
    // Fetch user data from your system
    const user = await db.users.findUnique({
      where: { referenceId: data.referenceId }
    });
    
    if (!user) {
      console.log(`User ${data.referenceId} not found`);
      return;
    }
    
    // Build the response with requested fields
    const userData = {};
    
    if (data.requiredFields.includes('name.secondName')) {
      userData.name = userData.name || {};
      userData.name.secondName = user.middleName;
    }
    
    if (data.requiredFields.includes('idNumber')) {
      userData.idNumber = user.governmentId;
    }
    
    if (data.requiredFields.includes('dob')) {
      userData.dob = user.dateOfBirth;
    }
    
    // Send the data to M2M
    await fetch(data.replyEndpoint, {
      method: 'PUT',
      headers: {
        'X-API-Key': process.env.M2M_API_KEY,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        referenceId: data.referenceId,
        userData
      })
    });
    
    return;
  }
  
  res.status(200).send('OK');
});
```

### What happens if you don't respond?

If you don't respond to the data request (or can't provide the data), M2M will prompt the user to enter the missing information directly in the widget. This increases friction but ensures the transaction can still complete.

See [Data Requests](/m2m/integration/data-requests) for more details on this flow.

***

## operation.created

Sent when a user completes a money transfer operation through the widget. Use this to record transactions in your system and trigger downstream processes.

### Payload

```json theme={null}
{
  "event": "operation.created",
  "timestamp": "2026-01-26T14:30:00.000Z",
  "data": {
    "operationId": "op_a1b2c3d4e5f6",
    "linkId": "link_acme_a1b2c3d4e5f6",
    "referenceId": "user_12345",
    "userId": "usr_x9y8z7w6v5u4",
    "status": "created",
    "funding": {
      "method": "card",
      "last4": "4242",
      "bankName": "visa"
    },
    "payout": {
      "method": "bank_account"
    },
    "amount": {
      "send": 100.00,
      "receive": 1850.50,
      "rate": 18.505,
      "fee": 3.99,
      "currency": {
        "send": "USD",
        "receive": "MXN"
      }
    },
    "createdAt": "2026-01-26T14:30:00.000Z"
  }
}
```

<ResponseField name="event" type="string" required>
  Always `operation.created`.
</ResponseField>

<ResponseField name="timestamp" type="string" required>
  ISO 8601 timestamp when the webhook was generated.
</ResponseField>

<ResponseField name="data" type="object" required>
  Event payload.

  <Expandable title="data properties">
    <ResponseField name="operationId" type="string" required>
      Unique identifier for the operation. Format: `op_{12chars}`.
    </ResponseField>

    <ResponseField name="linkId" type="string" required>
      The magic link identifier.
    </ResponseField>

    <ResponseField name="referenceId" type="string" required>
      Your user identifier.
    </ResponseField>

    <ResponseField name="userId" type="string" required>
      M2M's internal user identifier.
    </ResponseField>

    <ResponseField name="status" type="string" required>
      Operation status. Initially `created`.
    </ResponseField>

    <ResponseField name="funding" type="object" required>
      Funding source details.

      <Expandable title="funding properties">
        <ResponseField name="method" type="string" required>
          Funding method used (`card`, `bank_account`, etc.).
        </ResponseField>

        <ResponseField name="last4" type="string">
          Last 4 digits (for card payments).
        </ResponseField>

        <ResponseField name="bankName" type="string">
          Card brand or bank name.
        </ResponseField>
      </Expandable>
    </ResponseField>

    <ResponseField name="payout" type="object" required>
      Payout destination details.

      <Expandable title="payout properties">
        <ResponseField name="method" type="string" required>
          Payout method (`bank_account`, `card`, `cash`).
        </ResponseField>
      </Expandable>
    </ResponseField>

    <ResponseField name="amount" type="object" required>
      Transaction amounts.

      <Expandable title="amount properties">
        <ResponseField name="send" type="number" required>
          Amount sent in source currency.
        </ResponseField>

        <ResponseField name="receive" type="number" required>
          Amount received in destination currency.
        </ResponseField>

        <ResponseField name="rate" type="number" required>
          Exchange rate applied.
        </ResponseField>

        <ResponseField name="fee" type="number" required>
          Transaction fee.
        </ResponseField>

        <ResponseField name="currency" type="object" required>
          Currency codes.

          <Expandable title="currency properties">
            <ResponseField name="send" type="string" required>
              Source currency (e.g., `USD`).
            </ResponseField>

            <ResponseField name="receive" type="string" required>
              Destination currency (e.g., `MXN`).
            </ResponseField>
          </Expandable>
        </ResponseField>
      </Expandable>
    </ResponseField>

    <ResponseField name="createdAt" type="string" required>
      ISO 8601 timestamp when the operation was created.
    </ResponseField>
  </Expandable>
</ResponseField>

### Example handler

```javascript Node.js theme={null}
app.post('/webhooks/m2m', async (req, res) => {
  const { event, data } = req.body;
  
  if (event === 'operation.created') {
    console.log(`Operation ${data.operationId} created for user ${data.referenceId}`);
    
    // Record the operation in your system
    await db.operations.create({
      operationId: data.operationId,
      referenceId: data.referenceId,
      sendAmount: data.amount.send,
      receiveAmount: data.amount.receive,
      fee: data.amount.fee,
      status: data.status,
      createdAt: data.createdAt
    });
    
    // Notify your team or trigger downstream processes
    await notifyTeam('New M2M operation', data);
  }
  
  res.status(200).send('OK');
});
```

***

## operation.cancelled

Sent when a user cancels a money transfer operation from the widget. Use this to update your records, release any held resources, and keep your system in sync.

<Warning>
  If you've already begun processing the operation on your side (e.g., provisional balance holds), make sure your cancellation handler reverses that state.
</Warning>

### Payload

```json theme={null}
{
  "event": "operation.cancelled",
  "timestamp": "2026-01-26T14:35:00.000Z",
  "data": {
    "operationId": "op_a1b2c3d4e5f6",
    "linkId": "link_acme_a1b2c3d4e5f6",
    "referenceId": "user_12345",
    "userId": "usr_x9y8z7w6v5u4",
    "status": "cancelled",
    "cancellationReason": "user_requested",
    "cancelledAt": "2026-01-26T14:35:00.000Z"
  }
}
```

<ResponseField name="event" type="string" required>
  Always `operation.cancelled`.
</ResponseField>

<ResponseField name="timestamp" type="string" required>
  ISO 8601 timestamp when the webhook was generated.
</ResponseField>

<ResponseField name="data" type="object" required>
  Event payload.

  <Expandable title="data properties">
    <ResponseField name="operationId" type="string" required>
      Unique identifier for the operation. Format: `op_{12chars}`.
    </ResponseField>

    <ResponseField name="linkId" type="string" required>
      The magic link identifier.
    </ResponseField>

    <ResponseField name="referenceId" type="string" required>
      Your user identifier.
    </ResponseField>

    <ResponseField name="userId" type="string" required>
      M2M's internal user identifier.
    </ResponseField>

    <ResponseField name="status" type="string" required>
      Operation status. Always `cancelled` for this event.
    </ResponseField>

    <ResponseField name="cancellationReason" type="string" required>
      Reason the operation was cancelled (e.g., `user_requested`).
    </ResponseField>

    <ResponseField name="cancelledAt" type="string" required>
      ISO 8601 timestamp when the operation was cancelled.
    </ResponseField>
  </Expandable>
</ResponseField>

### Example handler

```javascript Node.js theme={null}
app.post('/webhooks/m2m', async (req, res) => {
  const { event, data } = req.body;
  
  if (event === 'operation.cancelled') {
    console.log(`Operation ${data.operationId} cancelled for user ${data.referenceId}`);
    
    // Update the operation status in your system
    await db.operations.update({
      where: { operationId: data.operationId },
      data: {
        status: 'cancelled',
        cancellationReason: data.cancellationReason,
        cancelledAt: data.cancelledAt
      }
    });
    
    // Release any held resources
    await releaseHeldFunds(data.operationId);
  }
  
  res.status(200).send('OK');
});
```

***

## Handling webhooks

### Complete example

Here's a complete webhook handler that processes all event types:

```javascript Node.js theme={null}
const express = require('express');
const crypto = require('crypto');

const app = express();

// Use raw body for signature verification
app.use('/webhooks/m2m', express.raw({ type: 'application/json' }));

app.post('/webhooks/m2m', async (req, res) => {
  // Verify signature
  const signature = req.headers['x-m2m-signature'];
  const timestamp = req.headers['x-m2m-timestamp'];
  
  if (!verifySignature(req.body.toString(), signature, timestamp)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Parse the body
  const { event, data } = JSON.parse(req.body);
  
  // Acknowledge immediately
  res.status(200).send('OK');
  
  // Process asynchronously
  try {
    switch (event) {
      case 'link.created':
        await handleLinkCreated(data);
        break;
      case 'link.opened':
        await handleLinkOpened(data);
        break;
      case 'user.data_request':
        await handleDataRequest(data);
        break;
      case 'operation.created':
        await handleOperationCreated(data);
        break;
      case 'operation.cancelled':
        await handleOperationCancelled(data);
        break;
      default:
        console.log(`Unknown event: ${event}`);
    }
  } catch (error) {
    console.error(`Error processing ${event}:`, error);
    // Don't throw - we already responded 200
  }
});

function verifySignature(payload, signature, timestamp) {
  const secret = process.env.M2M_WEBHOOK_SECRET;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature.replace('sha256=', '')),
    Buffer.from(expected)
  );
}

async function handleLinkCreated(data) {
  console.log(`Link created: ${data.linkId} for user ${data.referenceId}`);
  // Your logic here - store the link, send it to the user, etc.
}

async function handleLinkOpened(data) {
  console.log(`Link opened: ${data.linkId} by user ${data.referenceId}`);
  // Your logic here
}

async function handleDataRequest(data) {
  console.log(`Data request: ${data.dataRequestId} for user ${data.referenceId}`);
  // Your logic here - fetch and send user data
}

async function handleOperationCreated(data) {
  console.log(`Operation created: ${data.operationId} for user ${data.referenceId}`);
  // Your logic here - record the operation, notify team, etc.
}

async function handleOperationCancelled(data) {
  console.log(`Operation cancelled: ${data.operationId} for user ${data.referenceId}`);
  // Your logic here - update status, release held resources, etc.
}

app.listen(3000);
```

## Next steps

<CardGroup cols={2}>
  <Card title="Webhook Security" icon="shield" href="/m2m/webhooks/security">
    Learn how to verify webhook signatures.
  </Card>

  <Card title="Data Requests" icon="database" href="/m2m/integration/data-requests">
    Deep dive into the data request flow.
  </Card>
</CardGroup>
