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

{
  "type": "ORDER_STATUS_UPDATED",
  "ref": "72672bab12",
  "created": "2023-10-05T17:38:26.698516-07:00",
  "data": {
    "order_ref": "93410bcaff",
    "status": "succeeded",
    "snap_total": "25.99",
    "ebt_cash_total": "5.99",
    "remaining_total": "10.99",
    "merchant_fns": "121212", 
    "merchant_id": "2fb3asd" 
  }
}
TypeDescriptionExample value
typestringA string representing the type of the event. Refer to Webhook events for available types."PAYMENT_STATUS_UPDATED"
refstringA unique string identifier for the event."72672bcxgq"
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.

🚧

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.

Webhook events

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 always either succeeded or canceled.

data.status == succeeded when:

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

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 five minutes it’s either still in draft status or one of its associated payments has failed

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.

{
  "type": "ORDER_STATUS_UPDATED",
  "ref": "72672bab12",
  "created": "2023-10-05T17:38:26.698516-07:00",
  "data": {
    "order_ref": "93410bcaff",
    "status": "succeeded",
    "snap_total": "25.99",
    "ebt_cash_total": "5.99",
    "remaining_total": "10.99",
    "merchant_fns": "121212", 
    "merchant_id": "2fb3asd" 
  }
}
{
  "ref": "bf97fec3fd",
  "created": "2024-02-07T22:23:49.056240+00:00",
  "type": "ORDER_STATUS_UPDATED",
  "data": {
    "order_ref": "13b84e670d",
    "status": "failed",
    "snap_total": "20.00",
    "ebt_cash_total": "0.00",
    "remaining_total": "0.00",
    "merchant_fns": "9000012",
    "merchant_id": "bacb08ea2a",
    "failure_reason": {
      "code": "55",
      "message": "Invalid PIN or PIN not selected - Invalid PIN"
    }
  }
}

ORDER_STATUS_UPDATED data attributes

TypeDescriptionExample value
order_refstringA unique identifier for the order."93410bcaff"
statusstringA string representing the order’s state in the processing cycle."succeeded"
snap_totalstringThe amount charged to the customer’s SNAP balance."25.99"
ebt_cash_totalstringThe amount charged to the customer’s EBT Cash balance."5.99"
remaining_totalstringThe amount charged to the customer’s non-EBT Card payment method."10.99"
merchant_fnsstringThe merchant's unique FNS number."121212"
merchant_idstringThe unique merchant ID that Forage provides during onboarding."2fb3asd"
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 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

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

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)

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

{
  "ref": "72672b13bb",
  "created": "2023-10-05T17:38:26.698516-07:00",
  "type": "PAYMENT_STATUS_UPDATED",
  "data": {
    "payment_ref": "cc3175bfea",
    "status": "succeeded",
    "amount": "25.99",
    "merchant_fns": "121212", 
    "merchant_id": "2fb3asd",
    "order_ref": "3a16426601"
  }
}
{
  "ref": "188011e42a",
  "created": "2024-01-31T18:28:44.186722+00:00",
  "type": "PAYMENT_STATUS_UPDATED",
  "data": {
    "payment_ref": "776aed69b3",
    "status": "failed",
    "amount": "10.00",
    "merchant_fns": "9000012",
    "merchant_id": "bacb08ea2a", 
    "failure_reason": {
      "code": "55",
      "message": "Invalid PIN or PIN not selected - Invalid PIN"
    }
  }
}

PAYMENT_STATUS_UPDATED data attributes

TypeDescriptionExample value
payment_refstringA unique identifier for the payment."93410bcaff"
statusstringA string representing the payment’s state in the processing cycle."succeeded"
amountstringThe amount charged 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"
order_ref*

*Only returned for OrderPayments created by a Forage Session. Not returned for Payments created via a Forage SDK.
stringA unique reference identifier for the parent Order that the Payment is associated with. "3a16426601"
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"

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

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

data.status == canceled when:

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

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",
    "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",
    "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"
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"

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.

Next steps