sailing Foudra MRP

Webhooks

Rather than ask Foudra over and over whether anything has changed, your program can be told. An admin adds a webhook under Webhooks in the app: an https address of yours and the events to send there. Foudra then POSTs a JSON message to that address each time one of those events happens. The reference lists the events and what each carries.

A receiver

Three things matter, and this receiver, standard library only, does all three:

  1. Check the signature, so you act only on messages that came from Foudra.
  2. Answer at once with 2xx, and do the work afterwards. No answer within 10 seconds counts as a failure and is tried again later.
  3. Expect repeats. A message can arrive twice; remember the event ids you've handled.
import hashlib
import hmac
import json
import os
import queue
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SECRET = os.environ["FOUDRA_WEBHOOK_SECRET"]  # shown once, when the webhook was made
work = queue.Queue()
seen = set()  # in real life, a table: it must survive a restart


def verify(header, body):
    """X-Foudra-Signature is t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<body>">."""
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    expected = hmac.new(SECRET.encode(), str(t).encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", "")) and abs(time.time() - t) < 300


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not verify(self.headers.get("X-Foudra-Signature", ""), body):
            self.send_response(401)
            self.end_headers()
            return
        work.put(json.loads(body))
        self.send_response(204)
        self.end_headers()


def worker():
    while True:
        event = work.get()
        if event["id"] in seen:
            continue
        seen.add(event["id"])
        if event["type"] == "work_order.completed":
            wo = event["data"]["work_order"]
            print("done:", wo["number"], wo["external_ref"])
        elif event["type"] == "stock.changed":
            print(event["text"])


threading.Thread(target=worker, daemon=True).start()
ThreadingHTTPServer(("", 8000), Receiver).serve_forever()

Put it behind your web server's https, add the webhook with its address, and press Send test: a ping event arrives, and the webhook's page shows the answer it got.

Check the signature over the body exactly as received, before parsing it: parsing and re-encoding the JSON changes the bytes.

When your receiver is down

Foudra tries again after 1 minute, 5, 15, an hour, 3, 6 and 12 hours, then gives up on that message. After eight failures in a row the webhook is switched off and a task on the dashboard says so. The webhook's page shows each delivery and lets an admin send one again.

Because of retries, a message can arrive after a later one. If order matters, use occurred_at; if you must not miss anything, also read the current state from the API now and then, rather than relying on every message arriving.