Skip to content

Documentation

Transactional Email Webhook API

Forward any email. Receive a signed JSON webhook. Integrate in minutes.

How It Works

1

Create a mapping

Give us your webhook URL and get a unique @bizportal.io address.

2

Forward emails

Forward bank alerts, payment notifications, and invoices to that address.

3

We deliver webhooks

BizPortal verifies SPF/DKIM/DMARC and delivers a signed JSON webhook to your endpoint within seconds.

4

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:

HeaderValue
Content-Typeapplication/json
X-Webhook-User-IDYour user ID
X-Webhook-App-IDYour application ID
X-Webhook-Email-AddressThe recipient @bizportal.io address
X-Webhook-TimestampUnix timestamp of delivery
X-Webhook-Signaturesha256=<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

FieldTypeDescription
message_idstringSMTP Message-ID header (may be empty)
fromstringOriginal sender from the From: header
forwarderstring?Forwarder domain — present only for SRS-forwarded mail (omitempty)
to[string]Recipient list
subjectstringEmail subject line
bodystringEmail 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.

📧 FNB email
🔄 BizPortal webhook
🔍 Parse email body
Order #552 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.