Webhooks
Webhooks are notifications that are automatically sent when specific predefined events occur.
Webhooks are commonly used, for instance, when payment processors need to notify a client or end user that a charge succeeded or when a subscription renews.
They replace polling. This polling might have been:
- Software polling for a status change either through a loop, or timed queries.
- Manually by a user explicitly polling expecting a response or pending status to be returned.
Each merchant defines their own set of webhooks. A merchant can only affect their set webhooks. They cannot view or modify other merchant's webhooks. Likewise, a partner can only affect merchants associated to them. They cannot affect merchants of other partners.
Required API Permissions
Required API permission: Webhooks
If the permission for webhooks needs to be granted, contact your integration support team.
Webhook Workflows
To create and manage Aurora webhooks, use the following procedures and workflows.
Creating Webhooks
1) Determine which event types are of interest.
To list these, use GET /v2/webhooks/event-types
An event type is an identifier describing a specific event. For example, the event may be when a payment is approved, a payment fails, or an API key is created. The list of available events is presented by Aurora and only those events can be selected from. Event types may be periodically added.
Webhooks can accept one or more event types. Each event type is evaluated independently. Selected events are not required to be related and may relate to different transaction types or workflows. For organizational reasons, multiple webhooks can be created. Each webhook can have its own set of event types.
The following is the list of available event types.
| Event Type | Description | Category | Event Id |
|---|---|---|---|
| payment.ach.scheduled | ACH transaction created and scheduled | ACH Payments | 9 |
| payment.ach.in_progress | ACH transaction submitted to network | ACH Payments | 10 |
| payment.ach.held | ACH transaction placed on hold for review | ACH Payments | 11 |
| payment.ach.released | ACH transaction released from hold | ACH Payments | 12 |
| payment.ach.cancelled | ACH transaction cancelled before processing | ACH Payments | 13 |
| payment.ach.cleared | ACH transaction successfully cleared | ACH Payments | 14 |
| payment.ach.charged_back | ACH transaction returned or charged back | ACH Payments | 15 |
| payment.ach.failed | ACH transaction failed due to processing error | ACH Payments | 16 |
| payment.ach.refunded | ACH transaction refunded to originator | ACH Payments | 17 |
| api_key.created | New API key created | API Keys | 31 |
| api_key.deleted | API key revoked or deleted | API Keys | 32 |
| payment.card.authorized | Card payment authorization approved | Card Payments | 3 |
| payment.card.captured | Authorized card payment captured | Card Payments | 4 |
| payment.card.declined | Card payment declined by issuer | Card Payments | 5 |
| payment.card.failed | Card payment failed due to processing error | Card Payments | 6 |
| payment.card.voided | Card authorization voided before capture | Card Payments | 7 |
| payment.card.refunded | Card payment refunded to cardholder | Card Payments | 8 |
| invoice.created | New invoice created | Invoices | 18 |
| invoice.paid | Invoice marked as paid | Invoices | 19 |
| merchant.created | New merchant account created | Merchants | 30 |
| quick_payment.created | Quick payment link created | Quick Payments | 25 |
| quick_payment.paid | Quick payment link paid | Quick Payments | 26 |
| settlement.batch.completed | Batch settlement has been processed and settled | Settlement | 2 |
| subscription.created | New subscription created | Subscriptions | 21 |
| subscription.paid | Subscription payment successfully collected | Subscriptions | 22 |
| subscription.payment_failed | Subscription payment attempt failed | Subscriptions | 23 |
| subscription.delinquent | Subscription entered delinquent state after repeated failures | Subscriptions | 24 |
| terminal.added | New terminal registered to account | Terminals | 33 |
| terminal.out_of_paper | Terminal paper roll is empty | Terminals | 35 |
2) Create the webhook
To create the webhook, use POST /v2/webhooks/endpoints
Three parameters must be included.
name. This is the name of the webhook. It is a friendly, free-formed name that is convenient to recognize or indicate it's purpose.endpointUrl. This is an HTTPS URL or IP address of the server accepting the webhook. The URL is provided by the client.eventTypes. This is the set, an array of strings, of all events the webhook will respond to. See the event types table above.
3) Test the webhook
This step is optional but recommended.
This verifies the webhook's endpoint (endpointUrl) is reachable by sending a ping.
To test the webhook, use POST /v2/webhooks/endpoints/{webhookId}/ping.
Webhook Success or Failure
A successful webhook delivery is when the client’s server (the endpointUrl) received, accepted, and acknowledged an incoming webhook. This is a valid confirmation (a 200 series response within the timeout limit) from the client’s endpoint.
The webhook may not be successfully delivered. Reasons include a response other than a 200 series response, timed out, DNS failure, or the connection was refused. In that case, it will be re-sent automatically over a period of time.
Failed deliveries are retried with exponential backoff:
| Attempt | Delay After Failure | Cumulative Time |
|---|---|---|
| 1 (initial) | — | 0 |
| 2 | 1 minute | ~1 min |
| 3 | 5 minutes | ~6 min |
| 4 | 30 minutes | ~36 min |
| 5 | 2 hours | ~2.5 hours |
| 6 | 8 hours | ~10.5 hours |
| 7 | 24 hours | ~34.5 hours |
The same values are returned with GET {{baseURL}}/v2/webhooks/delivery-logs/{{webhookId}}
nextRetryAt. This indicates the date-time of the next try.attemptNumber. This indicates the number of the upcoming attempt.
If the webhook could never be successfully delivered, it is:
- Cancelled. No further attempts will be made.
- Marked as failed. This will be entered in the delivery log files.
An unsuccessful webhook delivery may be manually retried.
To retry a failed delivery, use POST /v2/webhooks/delivery-logs/{webhookId}/retry
Managing Webhooks
The following set of endpoints manage webhooks.
- This endpoint provides a list of all available webhook for the merchant.
To list all the available webhooks, useGET /v2/webhooks/endpoints - This endpoint retrieves webhook details specified by its webhookId.
To retrieve a specific webhook, useGET /v2/webhooks/endpoints/{webhookId} - This endpoint provides an ability to update or change a webhook.
For example, the set of event types may be changed without having to create a new webhook.
To modify or update a specific webhook, usePUT /v2/webhooks/endpoints/{webhookId} - This endpoint deletes the specific webhook.
This action can neither be undone nor can the webhook be retrieved.
This does not erase entries in the log file.
To delete a specific webhook, useDELETE /v2/webhooks/endpoints/{webhookId}
Monitoring Webhooks
The following set of endpoints monitor webhooks. These may be used to verify success of an attempt or assist in determining the cause of a problem.
- This endpoint lists success level of delivery attempts.
To view webhooks delivery attempts log file, useGET /v2/webhooks/delivery-logs - This endpoint lists success level of delivery attempts for a specified webhook.
To view a specified webhook delivery attempts log file, useGET /v2/webhooks/delivery-logs/{webhookId} - This endpoint exports the delivery logs file.
It may be specified as either a CSV or JSON format.
To export webhooks delivery attempts log file, useGET /v2/webhooks/delivery-logs/export - This endpoint retries a failed delivery.
To retry a failed delivery, usePOST /v2/webhooks/delivery-logs/{id}/retry
Client Handling of Messages
The webhook sends a message to the URL specified by the client.
This is endpointUrl of POST {{baseURL}}/v2/webhooks/endpoints.
The message is considered successfully sent after a send confirmation is received.
If that send confirmation was not successfully confirmed, the message will be sent, or retried, a number of times before failing.
After being successfully sent to the receiving endpoint or URL, it is the client's responsibility to handle it after that. As a best practice, we recommend that the message is first verified to be from Aurora. This step greatly increases security and reduces system vulnerabilities such as spoofing, tampering, or data injection. The IP address could be blocked to prevent future intrusions.
HMAC Verification
To use HMAC verification:
- Retrieve your signing secret from your secure environment variables.
- Capture the raw request body as a string before any JSON parsing occurs.
- Extract the signature value from the webhook-signature header (the part after the v1,). Also extract headers webhook-id and webhook-timestamp. These will be used on the next step.
- Generate an HMAC-SHA256 hash using the secret as the key and the raw body as the message.
Content to hash:
{eventId}.{eventTimestamp}.{rawBody} - Encode the generated hash to Base64 to match the header format.
- Perform a constant-time comparison between your generated signature and the one from the header.
The following code examples illustrate HMAC verification.
- Java
- C#
- Go
- JavaScript
- PHP
- Python
- Ruby
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.List;
import java.util.Map;
public class WebhookExample {
// In production, load this from an environment variable or a secrets manager.
// Never hardcode secrets in source code. Example: System.getenv("WEBHOOK_SECRET")
private static final String WEBHOOK_SECRET = "your_webhook_secret_here";
private static final int PORT = 3000;
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
server.createContext("/webhook-listener", WebhookExample::handleRequest);
server.start();
System.out.println("Webhook listener running on port " + PORT);
}
private static void handleRequest(HttpExchange exchange) throws IOException {
if (!exchange.getRequestMethod().equalsIgnoreCase("POST")) {
sendResponse(exchange, 404, "Not Found");
return;
}
// Read the raw body before any parsing.
byte[] rawBodyBytes = exchange.getRequestBody().readAllBytes();
String rawBody = new String(rawBodyBytes, StandardCharsets.UTF_8);
if (!verifySignature(exchange, rawBody)) {
sendResponse(exchange, 401, "Unauthorized");
return;
}
// Acknowledge receipt before processing so the payment processor doesn't time out.
sendResponse(exchange, 200, "OK");
// Parse and handle the event after responding.
handleEvent(rawBody);
}
private static boolean verifySignature(HttpExchange exchange, String rawBody) {
Map<String, List<String>> headers = exchange.getRequestHeaders();
String header = getHeader(headers, "webhook-signature");
String eventId = getHeader(headers, "webhook-id");
String eventTimestamp = getHeader(headers, "webhook-timestamp");
if (header == null || eventId == null || eventTimestamp == null) return false;
String[] parts = header.split(",", 2);
if (parts.length < 2) return false;
String receivedSignature = parts[1];
try {
// The signed payload is the event ID, timestamp, and raw body concatenated with dots.
String signedPayload = eventId + "." + eventTimestamp + "." + rawBody;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(WEBHOOK_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String computedSignature = Base64.getEncoder().encodeToString(
mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8))
);
return MessageDigest.isEqual(
receivedSignature.getBytes(StandardCharsets.UTF_8),
computedSignature.getBytes(StandardCharsets.UTF_8)
);
} catch (Exception e) {
return false;
}
}
// Called after the webhook signature has been verified. Add your business logic here
// to handle each event type and trigger the appropriate actions in your application.
private static void handleEvent(String rawBody) {
// TODO: parse rawBody into your preferred JSON model and handle by event type.
// Example: if ("transaction.card.captured".equals(event.getType())) { ... }
System.out.println("Received event: " + rawBody);
}
private static String getHeader(Map<String, List<String>> headers, String name) {
List<String> values = headers.get(name);
return (values != null && !values.isEmpty()) ? values.get(0) : null;
}
private static void sendResponse(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
// In production, load this from an environment variable or a secrets manager.
// Never hardcode secrets in source code. Example: Environment.GetEnvironmentVariable("WEBHOOK_SECRET")
const string webhookSecret = "your_webhook_secret_here";
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
bool VerifySignature(HttpRequest req, string rawBody)
{
string? header = req.Headers["webhook-signature"];
string? eventId = req.Headers["webhook-id"];
string? eventTimestamp = req.Headers["webhook-timestamp"];
if (string.IsNullOrEmpty(header) || string.IsNullOrEmpty(eventId) || string.IsNullOrEmpty(eventTimestamp))
return false;
var parts = header.Split(',', 2);
if (parts.Length < 2) return false;
string receivedSignature = parts[1];
// The signed payload is the event ID, timestamp, and raw body concatenated with dots.
string signedPayload = $"{eventId}.{eventTimestamp}.{rawBody}";
byte[] keyBytes = Encoding.UTF8.GetBytes(webhookSecret);
byte[] payloadBytes = Encoding.UTF8.GetBytes(signedPayload);
byte[] hash = HMACSHA256.HashData(keyBytes, payloadBytes);
string computedSignature = Convert.ToBase64String(hash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(receivedSignature),
Encoding.UTF8.GetBytes(computedSignature)
);
}
// Called after the webhook signature has been verified. Add your business logic here
// to handle each event type and trigger the appropriate actions in your application.
void HandleEvent(JsonDocument eventDoc)
{
// TODO: handle the event based on eventDoc.RootElement.GetProperty("type").GetString()
// Example: if (type == "transaction.card.captured") { ... }
Console.WriteLine($"Received event: {eventDoc.RootElement.GetProperty("type").GetString()}");
}
app.MapPost("/webhook-listener", async (HttpContext ctx) =>
{
// Read the raw body before any parsing.
using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8);
string rawBody = await reader.ReadToEndAsync();
if (!VerifySignature(ctx.Request, rawBody))
return Results.Unauthorized();
JsonDocument? eventDoc;
try
{
eventDoc = JsonDocument.Parse(rawBody);
}
catch (JsonException)
{
return Results.BadRequest();
}
// Acknowledge receipt before processing so the payment processor doesn't time out.
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync("OK");
await ctx.Response.CompleteAsync();
HandleEvent(eventDoc);
return Results.Empty;
});
app.Run($"http://localhost:3000");
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
)
// In production, load this from an environment variable or a secrets manager.
// Never hardcode secrets in source code. Example: os.Getenv("WEBHOOK_SECRET")
const webhookSecret = "your_webhook_secret_here"
const port = 3000
func verifySignature(r *http.Request, rawBody []byte) bool {
header := r.Header.Get("webhook-signature")
eventID := r.Header.Get("webhook-id")
eventTimestamp := r.Header.Get("webhook-timestamp")
if header == "" || eventID == "" || eventTimestamp == "" {
return false
}
parts := strings.SplitN(header, ",", 2)
if len(parts) < 2 {
return false
}
receivedSignature := parts[1]
// The signed payload is the event ID, timestamp, and raw body concatenated with dots.
signedPayload := fmt.Sprintf("%s.%s.%s", eventID, eventTimestamp, string(rawBody))
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
computedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(receivedSignature), []byte(computedSignature))
}
// Called after the webhook signature has been verified. Add your business logic here
// to handle each event type and trigger the appropriate actions in your application.
func handleEvent(event map[string]interface{}) {
// TODO: handle the event based on event["type"]
// Example: if event["type"] == "transaction.card.captured" { ... }
log.Printf("Received event: %s %v", event["type"], event)
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
// Read the raw body before any parsing.
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
defer r.Body.Close()
if !verifySignature(r, rawBody) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var event map[string]interface{}
if err := json.Unmarshal(rawBody, &event); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Acknowledge receipt before processing so the payment processor doesn't time out.
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
go handleEvent(event)
}
func main() {
http.HandleFunc("/webhook-listener", webhookHandler)
log.Printf("Webhook listener running on port %d", port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
}
const express = require('express');
const crypto = require('crypto');
// In production, load this from an environment variable or a secrets manager.
// Never hardcode secrets in source code. Example: load from environment variable "process.env.WEBHOOK_SECRET"
const WEBHOOK_SECRET = 'your_webhook_secret_here';
const PORT = 3000;
const app = express();
function verifySignature(req) {
const header = req.headers['webhook-signature'];
const eventId = req.headers['webhook-id'];
const eventTimestamp = req.headers['webhook-timestamp'];
if (!header || !eventId || !eventTimestamp) return false;
const receivedSignature = header.split(',')[1];
if (!receivedSignature) return false;
// req.body is a raw Buffer when using express.raw()
const rawBody = req.body.toString('utf8');
// The signed payload is the event ID, timestamp, and raw body concatenated with dots.
const signedPayload = `${eventId}.${eventTimestamp}.${rawBody}`;
const computedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('base64');
try {
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(computedSignature)
);
} catch {
return false;
}
}
// Called after the webhook signature has been verified. Add your business logic here
// to handle each event type and trigger the appropriate actions in your application.
function handleEvent(event) {
// TODO: handle the event based on event.type
// Example: if (event.type === 'transaction.card.captured') { ... }
console.log(`Received event: ${event.type}`, event);
}
// express.raw() preserves the body as a Buffer, required for HMAC verification.
// Do not use express.json() here — parsing the body first would break the signature check.
app.post('/webhook-listener', express.raw({ type: '*/*' }), (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send('Unauthorized');
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).send('Bad Request');
}
// Acknowledge receipt before processing so the payment processor doesn't time out.
res.status(200).send('OK');
handleEvent(event);
});
app.listen(PORT, () => {
console.log(`Webhook listener running on port ${PORT}`);
});
<?php
// In production, load this from an environment variable or a secrets manager.
// Never hardcode secrets in source code. Example: getenv("WEBHOOK_SECRET")
const WEBHOOK_SECRET = 'your_webhook_secret_here';
function verifySignature(string $rawBody): bool
{
$header = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$eventTimestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
if (!$header || !$eventId || !$eventTimestamp) {
return false;
}
$parts = explode(',', $header);
if (count($parts) < 2) {
return false;
}
$receivedSignature = $parts[1];
// The signed payload is the event ID, timestamp, and raw body concatenated with dots.
$signedPayload = "{$eventId}.{$eventTimestamp}.{$rawBody}";
$computedSignature = base64_encode(
hash_hmac('sha256', $signedPayload, WEBHOOK_SECRET, true)
);
return hash_equals($receivedSignature, $computedSignature);
}
// Called after the webhook signature has been verified. Add your business logic here
// to handle each event type and trigger the appropriate actions in your application.
function handleEvent(array $event): void
{
// TODO: handle the event based on $event['type']
// Example: if ($event['type'] === 'transaction.card.captured') { ... }
error_log("Received event: {$event['type']} " . json_encode($event));
}
// Read the raw request body before any parsing.
$rawBody = file_get_contents('php://input');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(404);
exit;
}
if (!verifySignature($rawBody)) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
$event = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo 'Bad Request';
exit;
}
// Acknowledge receipt before processing so the payment processor doesn't time out.
http_response_code(200);
echo 'OK';
// Flush the response to the client before continuing processing.
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
handleEvent($event);
import hashlib
import hmac
import json
import os
from flask import Flask, request, abort
# In production, load this from an environment variable or a secrets manager.
# Never hardcode secrets in source code. Example: os.environ.get("WEBHOOK_SECRET")
WEBHOOK_SECRET = "your_webhook_secret_here"
PORT = 3000
app = Flask(__name__)
def verify_signature(req):
header = req.headers.get("webhook-signature")
event_id = req.headers.get("webhook-id")
event_timestamp = req.headers.get("webhook-timestamp")
if not header or not event_id or not event_timestamp:
return False
parts = header.split(",")
if len(parts) < 2:
return False
received_signature = parts[1]
# The request body must be read as raw bytes before any parsing.
raw_body = req.get_data(as_text=True)
# The signed payload is the event ID, timestamp, and raw body concatenated with dots.
signed_payload = f"{event_id}.{event_timestamp}.{raw_body}"
computed_signature = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).digest()
import base64
computed_signature_b64 = base64.b64encode(computed_signature).decode("utf-8")
return hmac.compare_digest(received_signature, computed_signature_b64)
# Called after the webhook signature has been verified. Add your business logic here
# to handle each event type and trigger the appropriate actions in your application.
def handle_event(event):
# TODO: handle the event based on event["type"]
# Example: if event["type"] == "transaction.card.captured": ...
print(f"Received event: {event['type']}", event)
@app.post("/webhook-listener")
def webhook_listener():
if not verify_signature(request):
abort(401)
try:
event = json.loads(request.get_data(as_text=True))
except json.JSONDecodeError:
abort(400)
# Acknowledge receipt before processing so the payment processor doesn't time out.
response = app.response_class(response="OK", status=200)
handle_event(event)
return response
if __name__ == "__main__":
print(f"Webhook listener running on port {PORT}")
app.run(port=PORT)
require 'openssl'
require 'base64'
require 'json'
require 'sinatra'
# In production, load this from an environment variable or a secrets manager.
# Never hardcode secrets in source code. Example: ENV["WEBHOOK_SECRET"]
WEBHOOK_SECRET = 'your_webhook_secret_here'
set :port, 3000
def verify_signature(request)
header = request.env['HTTP_WEBHOOK_SIGNATURE']
event_id = request.env['HTTP_WEBHOOK_ID']
event_timestamp = request.env['HTTP_WEBHOOK_TIMESTAMP']
return false unless header && event_id && event_timestamp
received_signature = header.split(',')[1]
return false unless received_signature
# The request body must be read as raw bytes before any parsing.
request.body.rewind
raw_body = request.body.read
# The signed payload is the event ID, timestamp, and raw body concatenated with dots.
signed_payload = "#{event_id}.#{event_timestamp}.#{raw_body}"
computed_signature = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', WEBHOOK_SECRET, signed_payload)
)
OpenSSL.fixed_length_secure_compare(received_signature, computed_signature)
rescue StandardError
false
end
# Called after the webhook signature has been verified. Add your business logic here
# to handle each event type and trigger the appropriate actions in your application.
def handle_event(event)
# TODO: handle the event based on event["type"]
# Example: if event["type"] == "transaction.card.captured" ...
puts "Received event: #{event['type']} #{event}"
end
# Sinatra reads the raw body before routing, so no special middleware is needed.
post '/webhook-listener' do
unless verify_signature(request)
halt 401, 'Unauthorized'
end
begin
request.body.rewind
event = JSON.parse(request.body.read)
rescue JSON::ParserError
halt 400, 'Bad Request'
end
# Acknowledge receipt before processing so the payment processor doesn't time out.
status 200
body 'OK'
handle_event(event)
end
Aftermath
Once the sender is verified, the client can process the message as they need.