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

> ## Agent Instructions
> OwnPay is licensed under AGPL-3.0 and is completely free - no licensing fees.
> Production docs URL: https://ownpay.org/docs - append .md to any page URL for clean markdown.
> OwnPay requires PHP 8.3+, MySQL/MariaDB, and Redis.
> MCP server available at https://ownpay.org/docs/mcp for programmatic documentation queries.
> Use root-relative links (e.g. /quickstart) for internal navigation - do NOT include /docs prefix.
> Plugin development: consult /developer/plugin-types/ pages for correct interfaces and manifests.

# Webhooks - Handle Signed Payment Event Callbacks

> Configure webhook endpoints and handle signed HTTP callbacks for payment.succeeded, refund, and dispute events with retries and signature verification.

OwnPay sends HTTP POST requests to your server when payment events occur. Webhooks let you react to events in real time without polling the API.

## How webhooks work

```text theme={null}
Customer pays -> OwnPay processes -> POST to your endpoint
                                       |
                               Verify signature
                                       |
                               Handle event
                                       |
                               Return 200 OK
```

OwnPay considers delivery successful when your endpoint returns **HTTP 2xx** within **10 seconds**. On failure, it retries with exponential backoff.

## Configuring an endpoint

1. Go to **Developers > Webhooks**
2. Click **Add Endpoint**
3. Enter your URL: `https://your-store.com/webhooks/ownpay`
4. Select events to receive
5. Copy the **Webhook Secret**

<Note>
  OwnPay only sends webhooks to HTTPS endpoints. Use [ngrok](https://ngrok.com/) for local development.
</Note>

## Event types

| Event                      | Description                               |
| -------------------------- | ----------------------------------------- |
| `payment.pending`          | Intent created, customer hasn't paid      |
| `payment.processing`       | Gateway confirmed, settlement in progress |
| `payment.paid`             | Payment fully confirmed and settled       |
| `payment.failed`           | Payment attempt failed                    |
| `payment.cancelled`        | Payment cancelled                         |
| `payment.refunded`         | Full refund issued                        |
| `payment.partial_refunded` | Partial refund issued                     |
| `link.visited`             | Payment link opened by customer           |
| `link.paid`                | Payment via link completed                |

## Request format

**Headers:**

```http theme={null}
Content-Type: application/json
X-OwnPay-Signature: sha256=<hmac_hex>
X-OwnPay-Timestamp: 1718000000
X-OwnPay-Event: payment.paid
```

**Body:**

```json theme={null}
{
  "event": "payment.paid",
  "created_at": "2024-06-15T10:30:00Z",
  "data": {
    "id": "pi_01j0x8kz7m3wd4b9qfhec5rtg6",
    "status": "paid",
    "amount": 1500,
    "currency": "BDT",
    "description": "Order #1042",
    "customer": {
      "name": "Rahim Uddin",
      "email": "rahim@example.com"
    },
    "metadata": {
      "order_id": "1042"
    }
  }
}
```

## Signature verification

<Warning>
  Always verify webhook signatures before processing any event.
</Warning>

The signature is computed as:

```text theme={null}
HMAC-SHA256(secret, timestamp + "." + raw_request_body)
```

### PHP verification

```php theme={null}
$signature = $_SERVER['HTTP_X_OWNPAY_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_OWNPAY_TIMESTAMP'] ?? '';
$rawBody = file_get_contents('php://input');

$secret = getenv('OWNPAY_WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true);
```

### Node.js verification

```javascript theme={null}
import crypto from 'crypto';

const signature = req.headers['x-ownpay-signature'];
const timestamp = req.headers['x-ownpay-timestamp'];
const rawBody = req.rawBody.toString();

const secret = process.env.OWNPAY_WEBHOOK_SECRET;
const hmac = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${rawBody}`)
  .digest('hex');

if (!crypto.timingSafeEqual(
  Buffer.from(`sha256=${hmac}`),
  Buffer.from(signature)
)) {
  return res.status(401).send('Invalid signature');
}
```

## Idempotency

Webhook delivery is at-least-once. Your handler may receive the same event more than once. Make all handlers idempotent:

```php theme={null}
function handlePaymentPaid(array $data): void
{
    $orderId = $data['metadata']['order_id'];
    $order = Order::find($orderId);
    
    if ($order->status === 'fulfilled') {
        return; // Already processed
    }
    
    $order->fulfill();
}
```

## Retry schedule

| Attempt | Delay         |
| ------- | ------------- |
| 1       | 5 minutes     |
| 2       | 15 minutes    |
| 3       | 1 hour        |
| 4       | 6 hours       |
| 5       | 12 hours      |
| 6       | 24 hours      |
| 7+      | Dead-lettered |

## Testing locally

Use ngrok to expose a local port:

```bash theme={null}
ngrok http 8080
# Use the ngrok URL in OwnPay webhook settings
```

You can also replay recent events from **Developers > Webhooks > Delivery Log**.


## Related topics

- [Features and Capabilities](/docs/resources/features.md)
- [Developer Quickstart - Build Your First Payment Integration](/docs/developer/quickstart.md)
- [OwnPay API Overview - REST API for Multi-Brand Payments](/docs/api/overview.md)
- [Event System - Dispatchable Events and Queued Handlers](/docs/developer/plugins/events.md)
- [Glossary - Payment Terms and OwnPay Terminology Reference](/docs/resources/glossary.md)
