HomeGuidesReference
Log In

Configure webhooks

Get real-time updates about orders, payments, and refunds

You can configure webhooks to send real-time updates of certain Forage events to endpoints that you define.

Webhooks are useful for maintaining state between the front-end and backend of your application.

For example, let’s say a customer completes the Forage Checkout UI and is redirected to your website's order confirmation page. This is the typical flow to alert a customer than an order is successful, however, it’s possible that the customer closes the client app before the app has a chance to send the response to your backend. Listening for an ORDER_STATUS_UPDATED webhook event ensures that your backend still gets the update and the order is fulfilled.

You can also use webhooks to trigger internal processes. For example, you can listen for a PAYMENT_STATUS_UPDATED webhook to kick off bookkeeping or updating the payment status in your database.

Example webhook payload

{
  "created": "2023-10-05T17:38:26.698516-07:00",
  "data": {
    "ebt_cash_total": "10.00",
    "external_order_id": "58503b96-5111-495b-b13b-d9247be12e75",
    "merchant_fns": "121212",
    "merchant_id": "2fb3asd",
    "order_ref": "3ee466e0ef",
    "payments": [
      {
        "amount": "10.00",
        "funding_type": "ebt_snap",
        "merchant_fns": "0256679",
        "merchant_id": "07839ae280",
        "order_ref": "3ee466e0ef",
        "payment_ref": "5fa6e45620",
        "status": "succeeded"
      }, 
      {
        "amount": "10.00",
        "funding_type": "ebt_cash",
        "merchant_fns": "0256679",
        "merchant_id": "07839ae280",
        "order_ref": "3ee466e0ef",
        "payment_ref": "sd7v223HsA",
        "status": "succeeded"
      } 
    ],
    "remaining_total": "0.00",
    "snap_total": "10.00",
    "status": "succeeded"
  },
  "ref": "72672bab12",
  "type": "ORDER_STATUS_UPDATED"
}

🚧

Some numeric values are represented as strings

The decimal values in webhook payloads are returned as strings. Refer to the API introduction for details on how to parse the data.

Common payload fields

All webhooks, no matter the type, return the following properties:

TypeDescriptionExample value
createddate-timeThe timestamp of event creation, represented as an ISO-8601 date-time string."2023-10-05T17:38:26.698516-07:00"
dataobjectAn object including event-specific information.Refer to Webhook events for example values corresponding to each event type.
refstringA unique string identifier for the event."72672bcxgq"
typestringA string representing the type of the event. Refer to Webhook events for available types."ORDER_STATUS_UPDATED"

SDK integrations

SDK integrations interact with Forage Payment and PaymentRefund resources. The following webhooks are relevant to SDK integrations.

PAYMENT_STATUS_UPDATED

The PAYMENT_STATUS_UPDATED webhook fires when the status of an EBT SNAP, EBT Cash, or credit/debit payment is updated. (Though only Fully Hosted integrations send credit/debit payment updates).

The webhook data.status attribute is always one of succeeded, failed, or canceled.

data.status == succeeded when:

  • The EBT state processor completes the payment and sends Forage a success response
  • The credit card portion of a Fully Hosted Checkout session is successfully captured

succeeded is a terminal state.

data.status == failed when:

  • The state processor cannot complete the payment and sends Forage a failure response, like if a customer doesn’t have sufficient funds on their EBT card
  • Forage is unable to contact the state processor
  • The credit card portion of a Fully Hosted Checkout session fails

failed is not a terminal state.

data.status == canceled when:

  • You manually cancel a payment via a POST to /payments/{payment_ref}/cancel/
  • If a payment is associated with an order, then Forage cancels the payment if any part of the order fails (for example, if a credit card payment fails for an order but a SNAP payment succeeds, both payments are canceled)

canceled is a terminal state.

You can listen for PAYMENT_STATUS_UPDATED to trigger processes like updating the payment status in your internal database and/or internal bookkeeping.

{
  "created": "2023-10-05T17:38:26.698516-07:00",
  "data": {
    "amount": "10.00",
    "external_order_id": "a238d043-5d88-4bd8-bdf5-769c83e2482e",
    "funding_type": "ebt_snap",
    "merchant_fns": "121212", 
    "merchant_id": "2fb3asd",
    "payment_ref": "3a16426601",
    "status": "succeeded",
  },
  "ref": "72672b13bb",
  "type": "PAYMENT_STATUS_UPDATED",
}
{
  "ref": "cd9e3b2c83",
  "created": "2024-05-21T14:50:57.861207+00:00",
  "type": "PAYMENT_STATUS_UPDATED",
  "data": {
    "payment_ref": "2a629162f4",
    "status": "failed",
    "amount": "20.00",
    "merchant_fns": "1234567",
    "merchant_id": "4f984ec909",
    "failure_reason": {
      "code": "ebt_error_51",
      "message": "Insufficient funds - Insufficient Funds. Remaining balances are SNAP: $100.00, EBT Cash: $100.00"
    },
    "funding_type": "ebt_cash",
    "previous_errors": [
      {
        "code": "ebt_error_51",
        "message": "Insufficient funds - Insufficient Funds. Remaining balances are SNAP: $100.00, EBT Cash: $100.00",
        "details": {
          "cash_balance": "100.00",
          "snap_balance": "100.00"
        },
        "source": {
          "resource": "Payments",
          "ref": "2a629162f4"
        }
      }
    ]
  }
}

PAYMENT_STATUS_UPDATED data attributes

TypeDescriptionExample value
amountstringThe amount charged to the payment method."10.00"
external_order_idstringA unique identifier for the order as created by the merchant or platform (not Forage)."a238d043-5d88-4bd8-bdf5-769c83e2482e"
funding_typestringA string that represents the type of tender. One of:

- benefit
- ebt_cash
- ebt_snap
ebt_snap
merchant_fnsstringThe merchant's unique FNS number."121212"
merchant_idstringThe unique merchant ID that Forage provides during onboarding."2fb3asd"
payment_refstringA unique reference identifier for the payment."3a16426601"
statusstringA string representing the payment’s state in the processing cycle."succeeded"
failure_reason*

*Failure responses only. Not returned on Success.
objectAn object with the following keys:

- code: A short string that helps identify the cause of the error. For example, "55" indicates that a customer entered an invalid EBT Card PIN.

- message: A developer-facing description of the error.Refer to the errors reference for common code and message pairs.
"55"

"Invalid PIN or PIN not selected - Invalid PIN"
previous_errorsarrayAn array of objects that include the code, message and source values corresponding to the most recent EBT network-related error associated with a canceled or failed Payment. This field is only returned when the data.status of the webhook is canceled or failed.Refer to the complete Failed example above.

REFUND_STATUS_UPDATED

The REFUND_STATUS_UPDATED webhook fires when the status of a refund is updated.

The webhook data.status attribute is always one of succeeded, failed, or canceled.

data.status == succeeded when:

  • The EBT state processor completes the refund and sends Forage a success response
  • A credit card refund for a Fully Hosted Checkout session is successful

succeeded is a terminal state.

data.status == failed when:

  • The EBT state processor cannot complete the refund and sends Forage a failure response
  • Forage is unable to contact the state processor
  • A credit card refund for a Fully Hosted Checkout session fails

failed is not a terminal state.

data.status == canceled when:

  • A refund is voided. In this rare case, the financial effect of the refund is reversed

canceled is a terminal state.

You can listen for REFUND_STATUS_UPDATED to trigger processes like marking an order as refunded in your internal database and/or internal bookkeeping.

This event fires for both order payment refunds, for Fully Hosted and Custom integrations, and payment refunds for SDK integrations.

{
  "ref": "72672bc724",
  "created": "2023-10-05T17:38:26.698516-07:00",
  "type": "REFUND_STATUS_UPDATED",
  "data": {
    "refund_ref": "87432dehkk",
    "external_order_id": "a238d043-5d88-4bd8-bdf5-769c83e2482e",
    "status": "succeeded",
    "amount": "25.99",
    "merchant_fns": "121212",
    "merchant_id": "2fb3asd",
    "payment_ref": "8e3c6a9d07"
  }
}
{
  "ref": "e1ecf255f4",
  "created": "2024-01-31T19:50:10.065453+00:00",
  "type": "REFUND_STATUS_UPDATED",
  "data": {
    "refund_ref": "60ddf6e386",
    "external_order_id": "a238d043-5d88-4bd8-bdf5-769c83e2482e",
    "status": "failed",
    "amount": "20.00",
    "merchant_fns": "9000012",
    "merchant_id": "bacb08ea2a",
    "payment_ref": "234ccb21d6",
    "failure_reason": {
      "code": "55",
      "message": "Invalid PIN or PIN not selected - Invalid PIN"
    }
  }
}

REFUND_STATUS_UPDATED data attributes

TypeDescriptionExample value
refund_refstringA unique identifier for the refund."87432dehkk"
statusstringA string representing the refund’s state in the processing cycle."succeeded"
external_order_idstringA unique identifier for the order as created by the merchant or platform (not Forage)."a238d043-5d88-4bd8-bdf5-769c83e2482e"
amountstringThe amount charged to be refunded to the payment method."25.99"
merchant_fnsstringThe merchant's unique FNS number."121212"
merchant_idstringThe unique merchant ID that Forage provides during onboarding."2fb3asd"
payment_refstringThe ref of the payment associated with this refund"8e3c6a9d07"
failure_reason*

*Failure responses only. Not returned on Success.
objectAn object with the following keys:

- code: A short string that helps identify the cause of the error. For example, "55" indicates that a customer entered an invalid EBT Card PIN.

- message: A developer-facing description of the error.Refer to the errors reference for common code and message pairs.
"55"

"Invalid PIN or PIN not selected - Invalid PIN"

Fully Hosted and Custom integrations

Fully Hosted and Custom integrations interact with Forage Order, OrderPayment, and OrderRefund resources.

You can use Forage webhooks to track the entire order status, in addition to individual payments and refunds associated with the order. Webhooks for order-related payments and refunds include an order_ref in the data field.

ORDER_STATUS_UPDATED

🚧

ORDER_STATUS_UPDATED is only available to Fully Hosted and Custom integrations.

If you’re building with a Forage SDK, then use the PAYMENT_STATUS_UPDATED or REFUND_STATUS_UPDATED webhooks.

The ORDER_STATUS_UPDATED webhook fires when the status of an order is updated.

The webhook data.status attribute is one of succeeded, canceled, or failed.

data.status == succeeded when:

  • All of the payments associated with an Order have successfully processed

succeeded is a terminal state.

data.status == failed when:

  • The EBT state processor cannot complete the order and sends Forage a failure response
  • Forage is unable to contact the state processor
  • A credit card refund for a Fully Hosted Checkout session fails

failed is not a terminal state.

data.status == canceled when:

  • You manually cancel an order via a POST to /orders/{ref}/cancel/
  • An order is latent, triggering Forage to cancel the order. An order is latent if after 30 minutes it’s either still in draft status or one of its associated payments has failed

canceled is a terminal state.

We recommend listening for ORDER_STATUS_UPDATED to validate the cost of an order. Comparing the amount captured by Forage against how much you expect the order to cost protects against fraudulent charges.

You can also listen for ORDER_STATUS_UPDATED to kick off internal processes like fulfilling the order, and/or updating the order status in your internal database.

🚧

ORDER_STATUS_UPDATED only fires on updates to an existing order. It is not triggered when an order is created.

{
  "created": "2023-10-05T17:38:26.698516-07:00",
  "data": {
    "ebt_cash_total": "10.00",
    "external_order_id": "58503b96-5111-495b-b13b-d9247be12e75",
    "merchant_fns": "121212",
    "merchant_id": "2fb3asd",
    "order_ref": "3ee466e0ef",
    "payments": [
      {
        "amount": "10.00",
        "funding_type": "ebt_snap",
        "merchant_fns": "0256679",
        "merchant_id": "07839ae280",
        "order_ref": "3ee466e0ef",
        "payment_ref": "5fa6e45620",
        "status": "succeeded"
      },
      {
        "amount": "10.00",
        "funding_type": "ebt_cash",
        "merchant_fns": "0256679",
        "merchant_id": "07839ae280",
        "order_ref": "3ee466e0ef",
        "payment_ref": "sd7v223HsA",
        "status": "succeeded"
      } 
    ],
    "remaining_total": "0.00",
    "snap_total": "10.00",
    "status": "succeeded"
  },
  "ref": "72672bab12",
  "type": "ORDER_STATUS_UPDATED"
}
{
  "ref": "d700e94235",
  "created": "2024-05-21T14:50:57.852878+00:00",
  "type": "ORDER_STATUS_UPDATED",
  "data": {
    "order_ref": "c8ac066123",
    "status": "failed",
    "snap_total": "0.00",
    "ebt_cash_total": "20.00",
    "remaining_total": "0.00",
    "merchant_fns": "1234789",
    "merchant_id": "4f984ec818",
    "failure_reason": {
      "code": "ebt_error_51",
      "message": "Insufficient funds - Insufficient Funds. Remaining balances are SNAP: $100.00, EBT Cash: $100.00"
    },
    "payments": [
      {
        "payment_ref": "2a629165G6",
        "status": "failed",
        "amount": "20.00",
        "merchant_fns": "1234567",
        "merchant_id": "4f984ec909",
        "order_ref": "c8ac066123",
        "failure_reason": {
          "code": "ebt_error_51",
          "message": "Insufficient funds - Insufficient Funds. Remaining balances are SNAP: $100.00, EBT Cash: $100.00"
        },
        "funding_type": "ebt_cash"
      }
    ],
    "previous_errors": [
      {
        "code": "ebt_error_51",
        "message": "Insufficient funds - Insufficient Funds. Remaining balances are SNAP: $100.00, EBT Cash: $100.00",
        "details": {
          "cash_balance": "100.00",
          "snap_balance": "100.00"
        },
        "source": {
          "resource": "Payments",
          "ref": "2a629162f4"
        }
      }
    ]
  }
}

ORDER_STATUS_UPDATED data attributes

TypeDescriptionExample value
ebt_cash_totalstringThe amount charged to the customer’s EBT Cash balance."10.00"
external_order_idstringA unique identifier for the order as created by the merchant or platform (not Forage)."58503b96-5111-495b-b13b-d9247be12e75"
merchant_fnsstringThe merchant's unique FNS number."121212"
merchant_idstringThe unique merchant ID that Forage provides during onboarding."2fb3asd"
order_refstringA unique identifier for the order."93410bcaff"
paymentsarrayAn array of objects that detail information about each payment associated with the order.Refer to payments.
remaining_totalstringThe amount charged to the customer’s non-EBT Card payment method."0.00"
snap_totalstringThe amount charged to the customer’s SNAP balance."10.00"
statusstringA string representing the order’s state in the processing cycle."succeeded"
previous_errorsarrayAn array of objects that include the code, message and source values corresponding to the most recent EBT network-related error associated with a canceled or failed Order. This field is only returned when the data.status of the webhook is canceled or failed.Refer to the complete Failed example above.

payments

Each object in the payments array contains the following information about an associated payment:

TypeDescriptionExample value
amountstringA positive decimal number that represents how the PaymentMethod was charged in USD. "10.00"
funding_typestringA string that represents the type of tender. One of:

- benefit
- ebt_cash
- ebt_snap
"ebt_snap"
merchant_fnsstringThe merchant's unique FNS number."025667"
merchant_idstringThe unique merchant ID that Forage provides during onboarding."07839ae280"
order_refstringA unique identifier for the order."3ee466e0ef"
payment_refstringA unique identifier for the payment."5fa6e45620"
statusstringA string representing the payment’s state in the processing cycle."succeeded"
failure_reasonobjectAn object with the following keys:

- code: A short string that helps identify the cause of the error. For example, "55" indicates that a customer entered an invalid EBT Card PIN.

- message: A developer-facing description of the error.Refer to the errors reference for common code and message pairs.
"55"

"Invalid PIN or PIN not selected - Invalid PIN"

PAYMENT_STATUS_UPDATED

The PAYMENT_STATUS_UPDATED webhook for Fully Hosted and Custom integrations is almost identical to the webhook for SDK integrations. The only difference is the addition of an order_ref in the data field, as in the following example:

{
  "created": "2023-10-05T17:38:26.698516-07:00",
  "data": {
    "amount": "10.00",
    "external_order_id": "a238d043-5d88-4bd8-bdf5-769c83e2482e",
    "funding_type": "ebt_snap",
    "merchant_fns": "121212", 
    "merchant_id": "2fb3asd",
    "payment_ref": "3a16426601",
    "status": "succeeded",
    "order_ref": "3a16426601"
  },
  "ref": "72672b13bb",
  "type": "PAYMENT_STATUS_UPDATED",
}
{
  "ref": "cd9e3b2c83",
  "created": "2024-05-21T14:50:57.861207+00:00",
  "type": "PAYMENT_STATUS_UPDATED",
  "data": {
    "payment_ref": "2a629162f4",
    "status": "failed",
    "amount": "20.00",
    "merchant_fns": "1234567",
    "merchant_id": "4f984ec909",
    "order_ref": "c8ac066560",
    "failure_reason": {
      "code": "ebt_error_51",
      "message": "Insufficient funds - Insufficient Funds. Remaining balances are SNAP: $100.00, EBT Cash: $100.00"
    },
    "funding_type": "ebt_cash",
    "previous_errors": [
      {
        "code": "ebt_error_51",
        "message": "Insufficient funds - Insufficient Funds. Remaining balances are SNAP: $100.00, EBT Cash: $100.00",
        "details": {
          "cash_balance": "100.00",
          "snap_balance": "100.00"
        },
        "source": {
          "resource": "Payments",
          "ref": "2a629162f4"
        }
      }
    ]
  }
}

The order_ref is a unique identifier for the parent Order that the Payment is attached to. For details on the other fields, refer to PAYMENT_STATUS_UPDATED data attributes.

REFUND_STATUS_UPDATED

The REFUND_STATUS_UPDATED webhook for Fully Hosted and Custom integrations is almost identical to the webhook for SDK integrations. The only difference is the addition of an order_ref in the data field, as in the following example:

{
  "ref": "72672bc724",
  "created": "2023-10-05T17:38:26.698516-07:00",
  "type": "REFUND_STATUS_UPDATED",
  "data": {
    "refund_ref": "87432dehkk",
    "external_order_id": "a238d043-5d88-4bd8-bdf5-769c83e2482e",
    "status": "succeeded",
    "amount": "25.99",
    "merchant_fns": "121212",
    "merchant_id": "2fb3asd",
    "payment_ref": "8e3c6a9d07", 
    "order_ref": "3a16426601"
  }
}
{
  "ref": "e1ecf255f4",
  "created": "2024-01-31T19:50:10.065453+00:00",
  "type": "REFUND_STATUS_UPDATED",
  "data": {
    "refund_ref": "60ddf6e386",
    "external_order_id": "a238d043-5d88-4bd8-bdf5-769c83e2482e",
    "status": "failed",
    "amount": "20.00",
    "merchant_fns": "9000012",
    "merchant_id": "bacb08ea2a",
    "payment_ref": "234ccb21d6",
    "order_ref": "3a16426601",
    "failure_reason": {
      "code": "55",
      "message": "Invalid PIN or PIN not selected - Invalid PIN"
    }
  }
}

The order_ref is a unique identifier for the parent Order that the Refund is attached to. For details on the other fields, refer to REFUND_STATUS_UPDATED attributes.

How to set up a webhook

After you’ve decided what webhook event or events to listen for, complete the following steps to configure a webhook:

  1. Set up your receiving endpoint
  2. Add the webhook to the Forage dashboard
  3. Verify the webhook signature
  4. Verify the webhook timestamp

Step 1: Set up your receiving endpoint

Your endpoint should be publicly available and accept a POST request with a JSON payload. Confirm that your endpoint returns a 200 when you receive the webhook. If the 200 isn’t received, then Forage retries the webhook with an exponential backoff.

Example endpoints

# Python Django Example
@api_view(http_method_names=["POST"])
def handle_webhook(request):
    event = request.data
    received_signature = request.headers.get("Webhook-Signature")
    # Validate signature (more instructions in Step 3)

    event_type = event["type"]

    # Handle the event
    if event_type == "ORDER_STATUS_UPDATED":
        # do something with order
        order_ref = event["data"]["order_ref"]
        pass
    elif event_type == "PAYMENT_STATUS_UPDATED":
        payment_ref = event["data"]["payment_ref"]
        pass
    elif event_type == "REFUND_STATUS_UPDATED":
        refund_ref = event["data"]["refund_ref"]
        pass

    # Return a 200 response to acknowledge receipt of the event
    return HttpResponse(status=200)

// Java Spring Example
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class WebhookController {

    @PostMapping("/mywebhookendpoint")
    public ResponseEntity<String> handleRequest(@RequestBody MyRequestData requestData, HttpServletRequest request) {
        String received_signature = request.getHeader("Webhook-Signature");
				// Validate signature (more instructions in Step 3)

        String eventType = requestData.getType();
        
        // Handle the event
				switch (eventType) {
						case "ORDER_STATUS_UPDATED": {
                break;
              }
						case "PAYMENT_STATUS_UPDATED": {
								break;
							}
						case "REFUND_STATUS_UPDATED": {
								break;
							}
						default:
								System.out.println("Webhook Event is unhandled");
				}
        
        // Return a 200 response to acknowledge receipt of the event
        String responseBody = "Processed Webhook";
        return new ResponseEntity<>(responseBody, HttpStatus.OK);
    }
}
// Node Example
const express = require('express');
const app = express();

app.post('/mywebhookendpoint', express.raw({type: 'application/json'}), (request, response) => {
  const signature = request.headers['Webhook-Signature'];
	// Validate signature (more instructions in Step 3)

  let event;

	event = request.body

	// Handle the event
	  switch (event.type) {
	    case 'ORDER_STATUS_UPDATED':
	      const orderRef = event.data.order_ref;
	      // Handle order ref
	      break;
	    case 'PAYMENT_STATUS_UPDATED':
				const paymentRef = event.data.payment_ref
				// Handle payment ref
				break;
	    case 'REFUND_STATUS_UPDATED':
				const refundRef = event.data.refund_ref
				// Handle refund ref
				break;
	    default:
	      console.log(`Event type ${event.type} not handled`);
	  }

  // Return a 200 response to acknowledge receipt of the event
  response.send();
});
require 'json'
require 'sinatra'
​
post '/webhook' do
  event = JSON.parse(request.body.read)
  received_signature = request.env['Webhook-Signature']
  # Validate signature (more instructions in Step 3)
​
  event_type = event['type']
​
  # Handle the event
  case event_type
  when 'ORDER_STATUS_UPDATED'
    # do something with order
    order_ref = event['data']['order_ref']
  when 'PAYMENT_STATUS_UPDATED'
    payment_ref = event['data']['payment_ref']
  when 'REFUND_STATUS_UPDATED'
    refund_ref = event['data']['refund_ref']
  end
  
  # Return a 200 response to acknowledge receipt of the event
  status 200
end

Webhook event ordering

Don't expect to receive webhooks in the same order that they're created. For example, when an order and its associated payments are captured, Forage generates both ORDER_STATUS_UPDATED and PAYMENT_STATUS_UPDATED events. While the payment event is technically generated before an order event, it's possible that the ORDER_STATUS_UPDATED webhook is delivered before PAYMENT_STATUS_UPDATED. You should handle the webhooks independently of any anticipated delivery order.

Step 2: Add the webhook to the Forage dashboard

Navigate to "Webhooks" in the Forage dashboard. From the Webhooks page, click "+Add webhook" and provide the requested information:

  • Webhook URL: The endpoint that receives the webhook (you set this up in Step 1)
  • Status: Whether the webhook is "Active", currently being listened for, or "Inactive"
  • Subscribed Events: The Forage webhook events to listen for
  • Notification Email: An email address that Forage can use to contact you in case of webhook errors

Step 3: Verify the webhook signature

Forage uses an HMAC with SHA-256 to sign its webhooks. The signature is passed in the webhook payload Webhook-Signature header, for example:

Webhook-Signature: be521964c21a8eb7f5ddd0f45b5bf83d8904a4dd4238b7b14f3eee73fa9c21f2

As a security measure, Forage requires that you validate the Webhook-Signature value against a signature that you calculate using the webhook’s secret from the Forage dashboard. This verifies that the webhook message came from Forage.

To calculate the signature, retrieve the webhook’s secret from the Forage dashboard and convert the webhook payload to a JSON string. Pass the key as the secret and the stringify’d payload as the message to a hash function to compute the HMAC with SHA-256. Finally, compare the signature that you computed with the Webhook-Signature.

Example comparison

🚧

Forage calculates the signature using the webhook's raw request body. To avoid miscalculating the signature, make sure that any framework that you're using doesn't manipulate the request body. For example, you might need to skip parsing if the framework removes whitespaces (e.g. Express.js).

🚧

Use a constant-time string comparison to compare the two signatures to avoid timing attacks.

import hashlib
import hmac

def get_hmac_string(secret, message):
    return hmac.new(
        secret.encode("utf-8"),
        message.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

# Django example
@api_view(http_method_names=["POST"])
def webhook_view(request):
    webhook_payload = request.body # ie. "{\"ref\": \"6ce5bdb204\", \"created\": \"2023-06-23T18:48:13.791077+00:00\", \"type\": \"ORDER_STATUS_UPDATED\", \"data\": {\"order_ref\": \"3b96a5312a\", \"status\": \"canceled\", \"snap_total\": \"20.00\", \"ebt_cash_total\": \"20.00\", \"remaining_total\": \"0.00\"}}"
    my_secret = "wh_secretabc123"
    # Compute expected signature
    expected_signature = get_hmac_string(my_secret, webhook_payload)
  
    received_signature = request.headers.get("Webhook-Signature")
  
    # Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
    if not hmac.compare_digest(expected_signature, received_signature):
        raise SignatureError()
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.MessageDigest;

public class WebhookHandler {
    
    public static String getHmacString(String secret, String message) throws NoSuchAlgorithmException, InvalidKeyException {
        Mac sha256Hmac = Mac.getInstance("HmacSHA256");
        SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        sha256Hmac.init(secretKey);
        byte[] hmacBytes = sha256Hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
        StringBuilder hexString = new StringBuilder();
        for (byte hmacByte : hmacBytes) {
            String hex = Integer.toHexString(0xff & hmacByte);
            if (hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }
        return hexString.toString();
    }
  
    public static boolean constantTimeEquals(String str1, String str2) throws NoSuchAlgorithmException {
        byte[] digest1 = getDigest(str1);
        byte[] digest2 = getDigest(str2);

        // Compare the digests using MessageDigest.isEqual()
        return MessageDigest.isEqual(digest1, digest2);
    }

    // Spring Boot example
    @PostMapping("/webhook")
    public void webhookHandler(@RequestBody String webhookPayload, @RequestHeader("Webhook-Signature") String receivedSignature) {
        String mySecret = "wh_secretabc123";
        try {
            // Compute expected signature
            String expectedSignature = getHmacString(mySecret, webhookPayload);

            // Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
            if (!constantTimeEquals(expectedSignature, receivedSignature)) {
                throw new SignatureError();
            }
            
            // Process the webhook payload
            // ...
        } catch (Exception e) {
            // Handle the exception
            e.printStackTrace();
        }
    }
}
const crypto = require('crypto');

function getHmacString(secret, message) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(message, 'utf8');
  return hmac.digest('hex');
}

// Express example
app.post('/webhook', (req, res) => {
  const webhookPayload = req.body.toString(); // assuming the request body contains the JSON payload
  const mySecret = 'wh_secretabc123';
  
  try {
    // Compute expected signature
    const expectedSignature = getHmacString(mySecret, webhookPayload);
  
    const receivedSignature = req.headers['webhook-signature'];
  
    // Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
    if (!crypto.timingSafeEqual(Buffer.from(expectedSignature, 'hex'), Buffer.from(receivedSignature, 'hex'))) {
      throw new Error('SignatureError');
    }
  
    // Process the webhook payload
    // ...
  
    res.sendStatus(200);
  } catch (error) {
    // Handle the error
    res.status(500).send(error.message);
  }
});
require 'openssl'
require 'base64'
require 'sinatra'

def get_hmac_string(secret, message)
  digest = OpenSSL::Digest.new('sha256')
  hmac = OpenSSL::HMAC.hexdigest(digest, secret, message)
  hmac
end

# Sinatra example
post '/webhook' do
  webhook_payload = request.body.read
  my_secret = 'wh_secretabc123'
  
  # Compute expected signature
  expected_signature = get_hmac_string(my_secret, webhook_payload)
  
  received_signature = request.env['HTTP_WEBHOOK_SIGNATURE']
  
  # Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
  unless Rack::Utils.secure_compare(expected_signature, received_signature)
    raise 'SignatureError'
  end
  
  # Process the webhook payload
  # ...
  
  status 200
end

Step 4: Verify timestamp

To further validate the webhook, compare the created value in the payload against your system’s timestamp for the corresponding resource. For example, confirm that the created value of a PAYMENT_STATUS_UPDATED webhook and the timestamp for when the payment was created in your system are within a few seconds, or whatever your risk tolerance is.

IP Addresses

Webhook notifications may originate from any of the following IP addresses:

54.71.62.121
35.81.99.127

Next steps