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:
Type | Description | Example value | |
---|---|---|---|
created | date-time | The timestamp of event creation, represented as an ISO-8601 date-time string. | "2023-10-05T17:38:26.698516-07:00" |
data | object | An object including event-specific information. | Refer to Webhook events for example values corresponding to each event type. |
ref | string | A unique string identifier for the event. | "72672bcxgq" |
type | string | A string representing the type of the event. Refer to Webhook events for available types. | "ORDER_STATUS_UPDATED" |
Onboarding events
If you’re using the the Forage Merchant Onboarding UI, then you can listen for webhooks that indicate when a merchant enters a different stage of the onboarding lifecycle. These webhooks include:
In addition to the common payload fields that all webhooks return, every onboarding event webhook details the following attributes:
Type | Description | Example value | |
---|---|---|---|
merchant_ref | string | The unique merchant ID that Forage provides during onboarding. | 36e7fcecbb |
merchant_fns | string | The merchant's unique FNS number. | 121212 |
name | string | The name of the merchant. | MerchantName |
store_number | string | A unique number provided by the platform (not Forage) to identify the merchant. | 123456 |
address | object | The merchant’s physical address. | { "line1": "1856 Market St.", "line2": null, "city": "San Francisco", "state": "CA", "zipcode": "94102", "country": "US" } |
timezone_offset | number | The number of hours between UTC and the merchant’s timezone. For example, if a merchant is based in EST, then the timezone_offset is -4 . If based in PST, then the value is -7 . | -7 |
is_physical_store | boolean | Whether this onboarded merchant represents a physical store location. | true |
contact_email | string | An email address that can be used to contact the merchant. | [email protected] |
chargeback_email | string | The email address that Forage uses to contact the merchant in case of chargebacks. | [email protected] |
agreed_to_tos | date-time | A timestamp of when the merchant signed the platform’s terms of service, represented as an ISO-8601 date-time string. | 2023-10-05T17:38:26.698516-07:00 |
customer_merchant_reference | string | A unique reference hash identifier for the onboarding merchant provided by the platform (not Forage). | 987xy12z34 |
go_live_date | date-time | A timestamp of when a merchant is live with Forage and can begin processing EBT, represented as an ISO-8601 date-time string. The actual live date may differ depending on the parent platform. | 2023-11-05T17:38:26.698516-07:00 |
MERCHANT_ONBOARDING_SUBMITTED
The MERCHANT_ONBOARDING_SUBMITTED
webhook fires when an onboarding merchant has completed the Onboarding UI and submitted the information to Forage.
{
"created": "2023-10-05T17:38:26.698516-07:00",
"data": {
"merchant_ref": "36e7fcecbb",
"fns_number": "121212",
"name": "MerchantName",
"store_number": "123456",
"address": {
"line1": "1856 Market St.",
"city": "San Francisco",
"state": "CA",
"zipcode": "94102",
"country": "US"
},
"timezone_offset": -7,
"is_physical_store": true,
"contact_email": "[email protected]",
"chargeback_email": "[email protected]",
"agreed_to_tos": "2023-10-05T17:38:26.698516-07:00",
"customer_merchant_reference": "987xy12z34",
"go_live_date": "2023-11-05T17:38:26.698516-07:00",
},
"ref": "72672b13bb",
"type": "MERCHANT_ONBOARDING_SUBMITTED",
}
MERCHANT_ONBOARDING_VERIFICATION_FAILED
Forage sends a MERCHANT_ONBOARDING_VERIFICATION_FAILED
webhook if the FNS number that the merchant submitted through the Onboarding UI is unable to be validated.
{
"created": "2023-10-05T17:38:26.698516-07:00",
"data": {
"merchant_ref": "36e7fcecbb",
"fns_number": "121212",
"name": "MerchantName",
"store_number": "123456",
"address": {
"line1": "1856 Market St.",
"city": "San Francisco",
"state": "CA",
"zipcode": "94102",
"country": "US"
},
"timezone_offset": -7,
"is_physical_store": true,
"contact_email": "[email protected]",
"chargeback_email": "[email protected]",
"agreed_to_tos": "2023-10-05T17:38:26.698516-07:00",
"customer_merchant_reference": "987xy12z34",
"go_live_date": "2023-11-05T17:38:26.698516-07:00",
},
"ref": "72672b13bb",
"type": "MERCHANT_ONBOARDING_VERIFICATION_FAILED",
}
MERCHANT_ONBOARDING_LIVE
The MERCHANT_ONBOARDING_LIVE
webhook fires when Forage has successfully verified and onboarded the merchant.
{
"created": "2023-10-05T17:38:26.698516-07:00",
"data": {
"merchant_ref": "36e7fcecbb",
"fns_number": "121212",
"name": "MerchantName",
"store_number": "123456",
"address": {
"line1": "1856 Market St.",
"city": "San Francisco",
"state": "CA",
"zipcode": "94102",
"country": "US"
},
"timezone_offset": -7,
"is_physical_store": true,
"contact_email": "[email protected]",
"chargeback_email": "[email protected]",
"agreed_to_tos": "2023-10-05T17:38:26.698516-07:00",
"customer_merchant_reference": "987xy12z34",
"go_live_date": "2023-11-05T17:38:26.698516-07:00",
},
"ref": "72672b13bb",
"type": "MERCHANT_ONBOARDING_LIVE",
}
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
data
attributesType | Description | Example value | |
---|---|---|---|
amount | string | The amount charged to the payment method. | "10.00" |
external_order_id | string | A unique identifier for the order as created by the merchant or platform (not Forage). | "a238d043-5d88-4bd8-bdf5-769c83e2482e" |
funding_type | string | A string that represents the type of tender. One of: - benefit - ebt_cash - ebt_snap | ebt_snap |
merchant_fns | string | The merchant's unique FNS number. | "121212" |
merchant_id | string | The unique merchant ID that Forage provides during onboarding. | "2fb3asd" |
payment_ref | string | A unique reference identifier for the payment. | "3a16426601" |
status | string | A string representing the payment’s state in the processing cycle. | "succeeded" |
failure_reason **Failure responses only. Not returned on Success. | object | An 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_errors | array | An 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
data
attributesType | Description | Example value | |
---|---|---|---|
refund_ref | string | A unique identifier for the refund. | "87432dehkk" |
status | string | A string representing the refund’s state in the processing cycle. | "succeeded" |
external_order_id | string | A unique identifier for the order as created by the merchant or platform (not Forage). | "a238d043-5d88-4bd8-bdf5-769c83e2482e" |
amount | string | The amount charged to be refunded to the payment method. | "25.99" |
merchant_fns | string | The merchant's unique FNS number. | "121212" |
merchant_id | string | The unique merchant ID that Forage provides during onboarding. | "2fb3asd" |
payment_ref | string | The ref of the payment associated with this refund | "8e3c6a9d07" |
failure_reason **Failure responses only. Not returned on Success. | object | An 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
orREFUND_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
data
attributesType | Description | Example value | |
---|---|---|---|
ebt_cash_total | string | The amount charged to the customer’s EBT Cash balance. | "10.00" |
external_order_id | string | A unique identifier for the order as created by the merchant or platform (not Forage). | "58503b96-5111-495b-b13b-d9247be12e75" |
merchant_fns | string | The merchant's unique FNS number. | "121212" |
merchant_id | string | The unique merchant ID that Forage provides during onboarding. | "2fb3asd" |
order_ref | string | A unique identifier for the order. | "93410bcaff" |
payments | array | An array of objects that detail information about each payment associated with the order. | Refer to payments . |
remaining_total | string | The amount charged to the customer’s non-EBT Card payment method. | "0.00" |
snap_total | string | The amount charged to the customer’s SNAP balance. | "10.00" |
status | string | A string representing the order’s state in the processing cycle. | "succeeded" |
previous_errors | array | An 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
payments
Each object in the payments
array contains the following information about an associated payment:
Type | Description | Example value | |
---|---|---|---|
amount | string | A positive decimal number that represents how the PaymentMethod was charged in USD. | "10.00" |
funding_type | string | A string that represents the type of tender. One of: - benefit - ebt_cash - ebt_snap | "ebt_snap" |
merchant_fns | string | The merchant's unique FNS number. | "025667" |
merchant_id | string | The unique merchant ID that Forage provides during onboarding. | "07839ae280" |
order_ref | string | A unique identifier for the order. | "3ee466e0ef" |
payment_ref | string | A unique identifier for the payment. | "5fa6e45620" |
status | string | A string representing the payment’s state in the processing cycle. | "succeeded" |
failure_reason | object | An 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:
- Set up your receiving endpoint
- Add the webhook to the Forage dashboard
- Verify the webhook signature
- 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
Expect a 2XX
response
2XX
responseA webhook call is not an API call. The implementation is expected to return 2XX
for any well-formed data regardless of whether the data is interpreted and consumed.
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
- Explore the Forage Payments API reference documentation
Updated 3 months ago