Documentation
Transactional Email Webhook API
Forward any email. Receive a signed JSON webhook. Integrate in minutes.
How It Works
Create a mapping
Give us your webhook URL and get a unique @bizportal.io address.
Forward emails
Forward bank alerts, payment notifications, and invoices to that address.
We deliver webhooks
BizPortal verifies SPF/DKIM/DMARC and delivers a signed JSON webhook to your endpoint within seconds.
Your app responds
Verify the HMAC signature, parse the payload, and respond — no polling, no inbox checking.
Webhook Headers
Every delivery POSTs to your webhook URL with these headers:
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Webhook-User-ID | Your user ID |
| X-Webhook-App-ID | Your application ID |
| X-Webhook-Email-Address | The recipient @bizportal.io address |
| X-Webhook-Timestamp | Unix timestamp of delivery |
| X-Webhook-Signature | sha256=<hex> |
Payload Shape
The from field always contains the original sender. The forwarder field only appears for SRS-forwarded mail (e.g., bank alerts forwarded through your mail provider).
SRS-Forwarded Mail (e.g. FNB bank alert via forwarder)
{
"message_id": "<abc123@mail.example.com>",
"from": "incontact@fnb.co.za",
"forwarder": "famgroup.co.za",
"to": ["alerts@something.bizportal.io"],
"subject": "FNB Payment Alert",
"body": "R550.00 paid to Current a/c..454463 @ Smartapp. Ref.552.
"
}Normal Mail (no forwarding)
{
"message_id": "<xyz789@mail.example.com>",
"from": "alerts@stripe.com",
"to": ["notify@something.bizportal.io"],
"subject": "Invoice Paid",
"body": "Invoice #INV-001 has been paid.
"
}Fields
| Field | Type | Description |
|---|---|---|
| message_id | string | SMTP Message-ID header (may be empty) |
| from | string | Original sender from the From: header |
| forwarder | string? | Forwarder domain — present only for SRS-forwarded mail (omitempty) |
| to | [string] | Recipient list |
| subject | string | Email subject line |
| body | string | Email body (plain text, \r\n line endings) |
Verifying Webhooks
Every webhook is HMAC-SHA256 signed using your webhook secret. Verify the signature before trusting the payload.
Signature format
X-Webhook-Signature: sha256=<hex>The signed payload is {timestamp}.{rawBody} — the unix timestamp string, a literal dot, then the raw JSON request body bytes.
Python (Django / Flask / FastAPI)
import hmac
import hashlib
import time
WEBHOOK_SECRET = "whsec_your_secret_here"
def verify_webhook(request_body: bytes, timestamp: str, signature: str) -> bool:
# 1. Reject stale requests (> 5 minutes)
if abs(int(time.time()) - int(timestamp)) > 300:
return False
# 2. Verify HMAC-SHA256(timestamp.body)
signed = f"{timestamp}.".encode() + request_body
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(),
signed,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
# Django view example
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
@csrf_exempt
@api_view(["POST"])
@permission_classes([AllowAny])
def webhook_receiver(request):
body = request.body
timestamp = request.headers.get("X-Webhook-Timestamp")
signature = request.headers.get("X-Webhook-Signature")
if not verify_webhook(body, timestamp, signature):
return Response({"error": "invalid signature"}, status=401)
# Parse and use the email
email = request.data
sender = email["from"]
subject = email["subject"]
forwarder = email.get("forwarder") # only present for SRS mail
# Your business logic here
# e.g.: match reference numbers, update orders, send notifications
return Response({"status": "ok"})Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
var webhookSecret = os.Getenv("WEBHOOK_SECRET")
type EmailWebhook struct {
MessageID string `json:"message_id"`
From string `json:"from"`
Forwarder *string `json:"forwarder,omitempty"`
To []string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
timestamp := r.Header.Get("X-Webhook-Timestamp")
sigHeader := r.Header.Get("X-Webhook-Signature")
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Now().Unix()-ts > 300 {
http.Error(w, "timestamp too old", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sigHeader), []byte(expected)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var email EmailWebhook
json.Unmarshal(rawBody, &email)
log.Printf("Received: %s from %s", email.Subject, email.From)
if email.Forwarder != nil {
log.Printf("Forwarded via: %s", *email.Forwarder)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}JavaScript (Express)
const crypto = require("crypto");
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
app.post("/webhook", express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}), (req, res) => {
const timestamp = req.headers["x-webhook-timestamp"];
const sigHeader = req.headers["x-webhook-signature"];
const rawBody = req.rawBody;
// 1. Reject stale requests (> 5 min)
if (Math.floor(Date.now() / 1000) - parseInt(timestamp) > 300) {
return res.status(400).json({ error: "timestamp too old" });
}
// 2. Verify HMAC-SHA256(timestamp.body)
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(
Buffer.from(sigHeader), Buffer.from(expected)
)) {
return res.status(401).json({ error: "invalid signature" });
}
// 3. Use the email
const { from, forwarder, subject, body } = req.body;
console.log(`Received: ${subject} from ${from}`);
if (forwarder) console.log(`Forwarded via: ${forwarder}`);
res.json({ status: "ok" });
});Real-world Example
Here's a complete Django endpoint that receives a BizPortal webhook for an FNB bank alert, parses the payment details from the email body, and automatically marks the matching order as paid.
import hmac
import hashlib
import json
import re
from decimal import Decimal
WEBHOOK_SECRET = "whsec_your_secret_here"
# ── Step 1: Receive and verify the webhook ──────────────────
@csrf_exempt
@api_view(["POST"])
@permission_classes([AllowAny])
def eft_webhook_view(request):
body = request.body
sig = request.headers.get("X-Webhook-Signature")
# Verify HMAC-SHA256(timestamp.body)
timestamp = request.headers.get("X-Webhook-Timestamp", "")
mac = hmac.new(WEBHOOK_SECRET.encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256)
if not hmac.compare_digest(sig, f"sha256={mac.hexdigest()}"):
return Response({"error": "invalid signature"}, status=403)
# ── Step 2: Parse the email ──────────────────────────────
# FNB alert: "R550.00 paid to Current a/c..454463. Ref.552."
payload = json.loads(body)
sender = payload["from"] # "incontact@fnb.co.za"
body_text = payload["body"]
# Extract structured data from the email body
amount = re.search(r"Rs?([d.,]+)", body_text)
ref = re.search(r"Ref.([^
.]+)", body_text, re.I)
amount_str = amount.group(1).replace(",", "") if amount else None
ref_str = ref.group(1).strip().split()[-1] if ref else None
# ── Step 3: Act on it ────────────────────────────────────
if not ref_str or not amount_str:
return Response({"status": "skipped"})
order = Order.objects.filter(id=ref_str).first()
if not order:
return Response({"status": "unknown_order"})
# Verify the email actually came from the bank
if sender.lower() != "incontact@fnb.co.za":
return Response({"error": "untrusted sender"}, status=403)
# Mark the order as paid
order.mark_paid(Decimal(amount_str), source="EFT")
# → Order #552 paid. Inventory updated. Customer notified.
# → No inbox checked. No manual entry.
return Response({"status": "order_updated", "order": ref_str})Result: Bank sends "R550.00 paid to Current a/c..454463. Ref.552." → BizPortal delivers the webhook → your endpoint extracts amount + reference → Order #552 is marked as paid. No inbox checked. No manual data entry.
Ready to Start Receiving Webhooks?
Create your free account, set up a mapping, and receive your first webhook in minutes.
BizPortal