Webhooks API

The Webhooks API enables real-time order lifecycle notifications to be sent to merchant systems.


Overview

Webhooks allow your systems to receive notifications when events occur, such as:

  • Order created
  • Order updated
  • Order fulfilled
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│                 │     │                 │     │                 │
│   Lorn AI       │────▶│   Webhook       │────▶│    Merchant     │
│   Gateway       │     │   with HMAC     │     │    System       │
│                 │     │   Signature     │     │                 │
└─────────────────┘     └─────────────────┘     └─────────────────┘

Emit Webhook

Emit a webhook event to a target URL.

POST /webhooks/emit

Request Body

FieldTypeRequiredDescription
event_typestringYesEvent type (e.g., order.created)
target_urlstringNoURL to POST the webhook to
checkout_session_idstringNoSession ID to include in payload
payloadobjectNoCustom payload data
signature_secretstringNoSecret for signing (default: demo_webhook_secret)

Response

{
  "delivered": 200,
  "signature": "t=1705312260,v1=abc123...",
  "payload": {
    "id": "evt_abc123",
    "type": "order.created",
    "created": "2024-01-15T10:30:00Z",
    "data": {
      "object": { ... }
    }
  }
}
FieldTypeDescription
deliveredinteger/nullHTTP status code from target, or null if not sent
signaturestringHMAC signature for verification
payloadobjectThe webhook payload that was/would be sent

Examples

cURL

# Emit order.created webhook
curl -X POST "https://{{YOUR_STORE_URL}}/webhooks/emit" \
  -H "Content-Type: application/json" \
  -H "X-ACP-API-Key: {{YOUR_API_KEY}}" \
  -d '{
    "event_type": "order.created",
    "checkout_session_id": "{{SESSION_ID}}",
    "target_url": "https://your-merchant.com/webhooks/lorn",
    "signature_secret": "your_webhook_secret"
  }'

Python

import requests
 
BASE_URL = "https://{{YOUR_STORE_URL}}"
HEADERS = {
    "Content-Type": "application/json",
    "X-ACP-API-Key": "{{YOUR_API_KEY}}"
}
 
def emit_webhook(
    event_type: str,
    checkout_session_id: str = None,
    target_url: str = None,
    payload: dict = None,
    signature_secret: str = None
) -> dict:
    body = {"event_type": event_type}
    
    if checkout_session_id:
        body["checkout_session_id"] = checkout_session_id
    if target_url:
        body["target_url"] = target_url
    if payload:
        body["payload"] = payload
    if signature_secret:
        body["signature_secret"] = signature_secret
    
    response = requests.post(
        f"{BASE_URL}/webhooks/emit",
        json=body,
        headers=HEADERS
    )
    response.raise_for_status()
    return response.json()
 
# Emit order.created with session data
result = emit_webhook(
    event_type="order.created",
    checkout_session_id="cs_demo_abc123",
    target_url="https://your-merchant.com/webhooks/lorn"
)
 
print(f"Delivered: {result['delivered']}")
print(f"Signature: {result['signature']}")

TypeScript

const BASE_URL = "https://{{YOUR_STORE_URL}}";
const API_KEY = "{{YOUR_API_KEY}}";
 
interface EmitWebhookInput {
  event_type: string;
  checkout_session_id?: string;
  target_url?: string;
  payload?: object;
  signature_secret?: string;
}
 
interface EmitWebhookResponse {
  delivered: number | null;
  signature: string;
  payload: object;
}
 
async function emitWebhook(input: EmitWebhookInput): Promise<EmitWebhookResponse> {
  const response = await fetch(`${BASE_URL}/webhooks/emit`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-ACP-API-Key": API_KEY
    },
    body: JSON.stringify(input)
  });
 
  if (!response.ok) {
    throw new Error(`Failed to emit webhook: ${response.statusText}`);
  }
 
  return response.json();
}
 
// Usage
const result = await emitWebhook({
  event_type: "order.created",
  checkout_session_id: "cs_demo_abc123",
  target_url: "https://your-merchant.com/webhooks/lorn"
});
 
console.log(`Delivered with status: ${result.delivered}`);

Java

public String emitWebhook(String eventType, String sessionId, String targetUrl) throws Exception {
    String requestBody = String.format("""
        {
            "event_type": "%s",
            "checkout_session_id": "%s",
            "target_url": "%s"
        }
        """, eventType, sessionId, targetUrl);
 
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(BASE_URL + "/webhooks/emit"))
        .header("Content-Type", "application/json")
        .header("X-ACP-API-Key", API_KEY)
        .POST(HttpRequest.BodyPublishers.ofString(requestBody))
        .build();
 
    HttpResponse<String> response = client.send(request, 
        HttpResponse.BodyHandlers.ofString());
    
    return response.body();
}

Go

type EmitWebhookInput struct {
    EventType         string                 `json:"event_type"`
    CheckoutSessionID string                 `json:"checkout_session_id,omitempty"`
    TargetURL         string                 `json:"target_url,omitempty"`
    Payload           map[string]interface{} `json:"payload,omitempty"`
    SignatureSecret   string                 `json:"signature_secret,omitempty"`
}
 
func emitWebhook(input EmitWebhookInput) (map[string]interface{}, error) {
    body, err := json.Marshal(input)
    if err != nil {
        return nil, err
    }
 
    req, err := http.NewRequest("POST", baseURL+"/webhooks/emit", bytes.NewBuffer(body))
    if err != nil {
        return nil, err
    }
 
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-ACP-API-Key", apiKey)
 
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
 
    respBody, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
 
    var result map[string]interface{}
    json.Unmarshal(respBody, &result)
    return result, nil
}

Event Types

Event TypeDescription
order.createdOrder was placed
order.updatedOrder status changed
order.fulfilledOrder was shipped/fulfilled
order.canceledOrder was canceled
order.refundedOrder was refunded

Webhook Payload Structure

{
  "id": "evt_abc123def456",
  "type": "order.created",
  "created": "2024-01-15T10:35:00Z",
  "data": {
    "object": {
      "id": "cs_demo_abc123",
      "status": "completed",
      "currency": "USD",
      "line_items": [...],
      "amounts": {
        "subtotal": 59.98,
        "tax": 4.80,
        "shipping": 7.99,
        "total": 72.77
      },
      ...
    }
  }
}

Webhook Signatures

Webhooks are signed using HMAC-SHA256 to verify authenticity.

Signature Header

Webhooks include the X-Webhook-Signature header:

X-Webhook-Signature: t=1705312260,v1=abc123def456...

Format: t=<timestamp>,v1=<hex_digest>

Verifying Signatures

Python

import hmac
import hashlib
 
def verify_webhook_signature(
    body: bytes,
    signature_header: str,
    secret: str,
    tolerance_seconds: int = 300
) -> bool:
    """Verify a webhook signature."""
    # Parse signature header
    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    timestamp = int(parts.get('t', 0))
    received_sig = parts.get('v1', '')
    
    # Check timestamp freshness (prevent replay attacks)
    import time
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    
    # Compute expected signature
    expected_sig = hmac.new(
        secret.encode('utf-8'),
        body,
        hashlib.sha256
    ).hexdigest()
    
    # Timing-safe comparison
    return hmac.compare_digest(expected_sig, received_sig)
 
 
# Usage in a Flask webhook handler
from flask import Flask, request
 
app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret"
 
@app.route('/webhooks/lorn', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature', '')
    
    if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
        return {'error': 'Invalid signature'}, 401
    
    event = request.json
    event_type = event.get('type')
    
    if event_type == 'order.created':
        # Handle new order
        order = event['data']['object']
        print(f"New order: {order['id']}")
    
    return {'received': True}

TypeScript

import crypto from 'crypto';
 
function verifyWebhookSignature(
  body: string,
  signatureHeader: string,
  secret: string,
  toleranceSeconds: number = 300
): boolean {
  // Parse signature header
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  );
  const timestamp = parseInt(parts.t || '0');
  const receivedSig = parts.v1 || '';
  
  // Check timestamp freshness
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    return false;
  }
  
  // Compute expected signature
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  
  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(receivedSig)
  );
}
 
// Usage in Express
import express from 'express';
 
const app = express();
const WEBHOOK_SECRET = 'your_webhook_secret';
 
app.post('/webhooks/lorn', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  
  if (!verifyWebhookSignature(req.body.toString(), signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  const event = JSON.parse(req.body.toString());
  
  switch (event.type) {
    case 'order.created':
      console.log(`New order: ${event.data.object.id}`);
      break;
    case 'order.fulfilled':
      console.log(`Order fulfilled: ${event.data.object.id}`);
      break;
  }
  
  res.json({ received: true });
});

Best Practices

1. Verify All Signatures

Always verify the webhook signature before processing:

if not verify_webhook_signature(body, signature, secret):
    return 401

2. Respond Quickly

Return a 200 response as soon as possible, then process asynchronously:

@app.route('/webhooks/lorn', methods=['POST'])
def handle_webhook():
    # Verify signature
    if not verify_signature(...):
        return 401
    
    # Queue for async processing
    queue.enqueue(process_webhook, request.json)
    
    # Return immediately
    return {'received': True}

3. Handle Retries

Webhooks may be retried. Use idempotency to prevent duplicate processing:

def process_webhook(event):
    event_id = event['id']
    
    # Check if already processed
    if redis.get(f"webhook:{event_id}"):
        return  # Already handled
    
    # Process the event
    handle_event(event)
    
    # Mark as processed (with TTL)
    redis.setex(f"webhook:{event_id}", 86400, "1")

4. Log Events

Keep an audit trail:

import logging
 
logger = logging.getLogger('webhooks')
 
def process_webhook(event):
    logger.info(f"Received webhook: type={event['type']} id={event['id']}")
    
    try:
        handle_event(event)
        logger.info(f"Processed webhook: id={event['id']}")
    except Exception as e:
        logger.error(f"Failed to process webhook: id={event['id']} error={e}")
        raise

Errors

StatusErrorDescription
400invalid_requestMissing required fields
502bad_gatewayFailed to deliver to target URL

Error Response

{
  "detail": "Failed to deliver webhook: Connection refused"
}

Testing Webhooks

Without Target URL

Omit target_url to get the signed payload without delivery:

curl -X POST "https://{{YOUR_STORE_URL}}/webhooks/emit" \
  -H "Content-Type: application/json" \
  -H "X-ACP-API-Key: {{YOUR_API_KEY}}" \
  -d '{
    "event_type": "order.created",
    "checkout_session_id": "{{SESSION_ID}}"
  }'

Response:

{
  "delivered": null,
  "signature": "t=1705312260,v1=...",
  "payload": { ... }
}

Use this to verify your signature verification logic.


See Also