---
title: "Handle Jovvie webhooks with the PHP SDKs"
description: "Receive Jovvie webhook events on your server, verify them with the SDK, and process each event once."
url: https://docs.jovvie.com/developers/webhooks/
section: "Developers"
last_updated: 2026-10-26
---

# Handle Jovvie webhooks with the PHP SDKs

Section: Developers

> Receive Jovvie webhook events on your server, verify them with the SDK, and process each event once.

This page shows you how to receive Jovvie webhooks on your server. A webhook is a request Jovvie sends to your server when something happens to a payment, such as a failure, a cancellation, or a refund. You verify each request with the SDK and the webhook key, then update your records.

Webhooks work alongside fetching payments. Always fetch the payment when the browser returns to your app, and use webhooks to learn about changes that happen later. See [Check a payment’s status](https://docs.jovvie.com/developers/check-payment-status/).

## Before you start

- You installed the SDK. See [Install the SDK](https://docs.jovvie.com/developers/install-the-sdk/).
- You have the merchant’s server application, with its **Webhook Key**. See [Get started](https://docs.jovvie.com/developers/get-started/).
- Your server has a public HTTPS address. Jovvie does not send webhooks to plain HTTP addresses.

## Set your webhook URL

1. Add a route in your app that accepts `POST` requests. This is your webhook endpoint.
2. Sign in to the Payments Dashboard.
3. Go to **Applications** and open the merchant’s server application.
4. In **Webhook URL**, enter the full HTTPS address of your webhook endpoint.
5. Click **Save Changes**.
6. Copy the **Webhook Key** from the same page and store it in your server configuration. The **Webhook Key** only appears after the application has a **Webhook URL**.

For NMI, use the **Applications** page of the Jovvie payments gateway dashboard. The fields are the same.

> **Caution**
>
> Keep the webhook key on your server, like the secret key. Never log it, put it in a URL, or send it to a browser or mobile app.

## Verify each webhook

Call `WebhookEvent::constructEvent()` with three values:

- The raw request body, exactly as received. Read it with `file_get_contents('php://input')`. Do not decode and re-encode it first.
- The value of the `X-Jovvie-Signature` request header.
- Your webhook key.

The SDK checks the signature with the webhook key. If the request is genuine, it returns an event object. If not, it throws a `WebhookError`. Reject every request that does not verify, and do not act on anything in it.

The example uses the Stripe SDK. For NMI, see [Adapt the example for NMI](https://docs.jovvie.com/developers/webhooks/#adapt-the-example-for-nmi).

```php
<?php
use JovviePayments\WebhookEvent;
use JovviePayments\WebhookEvents\Error\WebhookError;

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_JOVVIE_SIGNATURE'] ?? '';
$webhookKey = 'YOUR_WEBHOOK_KEY'; // Load this from your server configuration.

try {
    $event = WebhookEvent::constructEvent($payload, $signature, $webhookKey);
} catch (WebhookError $e) {
    if (strpos($e->getMessage(), 'Unsupported event type') === 0) {
        // The signature is valid, but this SDK version does not know the event type.
        http_response_code(200);
        exit;
    }

    // Not verified. Reject it.
    http_response_code(400);
    exit;
}

// Ignore events from the other mode, so a test event never changes a live order.
$appMode = 'YOUR_APP_MODE'; // 'live' or 'test'. Load this from your server configuration.
if ($event->getPaymentMode() !== $appMode) {
    http_response_code(200);
    exit;
}

yourApp_beginTransaction();

try {
    // Your events table has a unique constraint on the event ID,
    // so this insert fails if the event was already handled.
    if (!yourApp_insertEventId($event->getId())) {
        yourApp_rollBack();
        http_response_code(200);
        exit;
    }

    $order = yourApp_findOrderByJovviePaymentId($event->getPaymentId());

    if (!$order) {
        // Keep the event so you can match it to an order later.
        yourApp_storeEventForReconciliation($event);
    } else {
        switch ($event->getType()) {
            case 'jovvie.payment.completed':
            case 'jovvie.payment.failed':
            case 'jovvie.payment.cancelled':
            case 'jovvie.payment.refunded':
                yourApp_syncPayment($order, $event);
                break;

            case 'jovvie.subscription.renewal.completed':
            case 'jovvie.subscription.renewal.failed':
            case 'jovvie.subscription.status.update':
            case 'jovvie.subscription.cancelled':
                yourApp_syncSubscription($order, $event);
                break;

            default:
                yourApp_storeEventForReconciliation($event);
        }
    }

    yourApp_commit();
} catch (\Throwable $e) {
    // Nothing was saved. Answer with an error so Jovvie sends the event again.
    yourApp_rollBack();
    http_response_code(500);
    exit;
}

http_response_code(200);
echo json_encode(['received' => true]);
```

The functions that start with `yourApp_` stand for your own code. For example, `yourApp_syncPayment()` fetches the payment with `payments()->get()` and updates the order to match. `yourApp_syncSubscription()` updates your subscription record. See [Recurring payments](https://docs.jovvie.com/developers/subscriptions/).

### Adapt the example for NMI

For NMI, change three things:

1. In the `use` lines, use the `JovviePaymentsGateway` namespace instead of `JovviePayments`.
2. Remove the mode check. NMI events do not have `getPaymentMode()`.
3. Replace the `switch` with one that uses the NMI event names. NMI has no subscription events.

```php
switch ($event->getType()) {
    case 'jovvie-gateway.payment.completed':
    case 'jovvie-gateway.payment.failed':
    case 'jovvie-gateway.payment.cancelled':
    case 'jovvie-gateway.payment.refunded':
        yourApp_syncPayment($order, $event);
        break;

    default:
        yourApp_storeEventForReconciliation($event);
}
```

If you only change the namespace, no NMI event matches the Stripe event names, and your handler never updates the order.

The SDK checks the signature before it looks at the event type. So when it throws a `WebhookError` that starts with `Unsupported event type`, the request did come from Jovvie, and you can answer with HTTP 200 and ignore it.

> **Caution**
>
> Use `WebhookEvent::constructEvent()`, not `WebhookEvent::constructFrom()`. `constructFrom()` reads an event without checking the signature.

## Process each event once

The same event can reach your server more than once, and two copies can arrive at the same time. Save the event ID and your changes together, in one database transaction:

1. Start a database transaction.
2. Insert the event ID from `$event->getId()` into a table that has a unique constraint on the event ID.
3. If the insert fails because the ID already exists, roll back, answer with HTTP 200, and stop. You already handled this event.
4. Find the order by the Jovvie payment ID. If there is no matching order, store the event so you can reconcile it later.
5. Otherwise, update your records.
6. Commit the transaction, then answer with HTTP 200.

If anything fails before the commit, roll back and answer with an error status, so Jovvie sends the event again.

Because of the unique constraint, only one copy of an event can be saved, even if two copies arrive at once. If your handler does something outside your database, such as sending an email, make that step safe to repeat as well, for example by recording it with the event ID.

Also check the current state before you change it. For example, if an order is already marked paid, do not mark it paid again.

## Respond quickly

Answer every verified webhook with an HTTP 200 status as soon as you can. Jovvie waits up to 5 seconds for a response.

If your server does not answer in time, or answers with an error status, Jovvie tries again within a few seconds, up to three attempts in total. After that, Jovvie does not send the event again. If your work takes longer, save the event and process it after you respond.

Because an event can be lost if all three attempts fail, also check pending payments on a schedule. See [Check a payment’s status](https://docs.jovvie.com/developers/check-payment-status/).

## Use the event data

Every event has these methods:

| Method           | What it returns                                                  |
| ---------------- | ---------------------------------------------------------------- |
| `getId()`        | The event ID. Use it to skip duplicates.                         |
| `getType()`      | The event type, such as `jovvie.payment.completed`.              |
| `getPayload()`   | The event data as an array.                                      |
| `getPaymentId()` | The Jovvie payment ID. Use it to find the order in your records. |

Match each event to your records by the Jovvie payment ID. Before you make a change that matters, such as marking an order paid, fetch the payment with `payments()->get()` and check its `status`.

Each event type also has its own methods. If a value is missing from the event, the method throws a `WebhookError`. Catch it, and fall back to fetching the payment.

### Stripe SDK events

All Stripe events also have `getPaymentMode()`, which returns `live` or `test`. Check it so a test event never changes a live order.

| Event type                              | When it is sent                           | Other methods                                                                                                                                             |
| --------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jovvie.payment.completed`              | A payment was paid.                       | `getPaymentIntentId()`, `getInvoiceAmount()`, `getInvoiceCurrency()`, `getInvoicePaid()`                                                                  |
| `jovvie.payment.failed`                 | A payment attempt failed.                 | `getPaymentIntentId()`, `getInvoiceAmount()`, `getInvoiceCurrency()`                                                                                      |
| `jovvie.payment.cancelled`              | A payment was cancelled.                  | `getPaymentIntentId()`, `getInvoiceAmount()`, `getInvoiceCurrency()`                                                                                      |
| `jovvie.payment.refunded`               | A payment was refunded, fully or in part. | `getPaymentIntentId()`, `getInvoiceAmount()` (the amount left after the refund), `getInvoiceRefund()` (the total refunded so far), `getInvoiceCurrency()` |
| `jovvie.subscription.renewal.completed` | A recurring payment renewed and was paid. | See [Recurring payments](https://docs.jovvie.com/developers/subscriptions/).                                                                              |
| `jovvie.subscription.renewal.failed`    | A renewal payment failed.                 | See [Recurring payments](https://docs.jovvie.com/developers/subscriptions/).                                                                              |
| `jovvie.subscription.status.update`     | A recurring payment changed status.       | See [Recurring payments](https://docs.jovvie.com/developers/subscriptions/).                                                                              |
| `jovvie.subscription.cancelled`         | A recurring payment was cancelled.        | See [Recurring payments](https://docs.jovvie.com/developers/subscriptions/).                                                                              |

Amounts are in the smallest currency unit.

> **Note**
>
> Do not rely on `jovvie.payment.completed` alone to mark a one-time payment paid. Always fetch the payment when the browser returns to your return URL. See [Create a payment (Stripe)](https://docs.jovvie.com/developers/create-a-payment/#confirm-the-payment).

### NMI SDK events

| Event type                         | When it is sent                    | Other methods                                                                                                                    |
| ---------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `jovvie-gateway.payment.completed` | A payment was paid.                | `getTransactionId()`, `getTransactionAmount()`, `getTransactionCurrency()`, `getTransactionPaid()`                               |
| `jovvie-gateway.payment.failed`    | A payment attempt failed.          | `getTransactionId()`, `getTransactionAmount()`, `getTransactionCurrency()`                                                       |
| `jovvie-gateway.payment.cancelled` | A payment was cancelled or voided. | `getTransactionId()`, `getTransactionAmount()`, `getTransactionCurrency()`                                                       |
| `jovvie-gateway.payment.refunded`  | A payment was refunded.            | `getTransactionId()`, `getTransactionAmount()`, `getTransactionRefund()` (the amount of this refund), `getTransactionCurrency()` |

`getTransactionId()` returns the NMI transaction ID. A cancelled payment may have no transaction, so `getTransactionId()` can throw a `WebhookError` for `jovvie-gateway.payment.cancelled`.

## Keep webhook data safe

- Do not log the webhook key, the signature header, or the full request body.
- Log only what you need to find a problem later, such as the event ID and type.
- Never trust data in a webhook request that did not verify.

## Related pages

- [Check a payment’s status](https://docs.jovvie.com/developers/check-payment-status/)
- [Recurring payments](https://docs.jovvie.com/developers/subscriptions/)
- [Refunds](https://docs.jovvie.com/developers/refunds/)
- [Handle errors](https://docs.jovvie.com/developers/errors/)
