Skip to content

Outbound webhooks

Berlanggan can push license lifecycle events to your product as they happen. This is optional and complements — never replaces — the pull-based POST /v1/validate heartbeat, which stays the source of truth for licence status.

Enabling

In Seller dashboard → Products → (product) → Developer webhook, set:

Field Purpose
Webhook URL HTTPS endpoint that receives POST requests.
Signing secret Shared secret used to sign each request (HMAC-SHA256). Keep it out of source control.

Leave the URL blank to disable. Events are only sent for products that carry a license_key deliverable.

Events

Event Fired when
license.issued A paid order provisioned a licence (order.paid).
license.renewed A subscription renewed for another period (subscription.renewed).
license.suspended A subscription was suspended and access revoked (subscription.suspended).

All three carry the same payload shape.

Request

POST https://your-app.example.com/berlanggan/webhook
Content-Type: application/json
User-Agent: Berlanggan-Webhook/1.0
X-Berlanggan-Signature: sha256=1f8ac10f23c5b8...
{
  "event": "license.issued",
  "order_public_id": "ord_9F3K2M7QP1",
  "customer_email": "buyer@example.com",
  "license_key": "XXXX-XXXX-XXXX",
  "plan": "InTourney Pro",
  "license_expires_at": "2027-06-01T00:00:00Z",
  "entitlements": { "SEATS": "5", "API_ACCESS": "true" },
  "seat_limit": 1
}
Field Notes
event One of the three event names above.
order_public_id The order that created or last renewed the licence. May be "" for a legacy subscription with no linked order.
customer_email The buyer's account e-mail.
license_key XXXX-XXXX-XXXX (Crockford Base32).
plan The plan name at time of sending.
license_expires_at ISO-8601 UTC, or null for a perpetual (one-time) licence. On license.renewed this is the new period end.
entitlements Flat {key: value} map — the same values returned by the activation API. Use these for feature gating.
seat_limit Integer seat count for the licence.

Verifying the signature

X-Berlanggan-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed by your signing secret. This is the same scheme Berlanggan uses to verify inbound webhooks from Duitku, Sumopod and kirim.chat.

Verify against the exact bytes you received — do not re-serialize the JSON first.

import hashlib, hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    provided = (header or "").removeprefix("sha256=")
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)
const crypto = require("crypto");

function verify(rawBody, header, secret) {
  const provided = (header || "").replace(/^sha256=/, "");
  const expected = crypto.createHmac("sha256", secret)
    .update(rawBody).digest("hex");
  return provided.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}
function verify(string $rawBody, string $header, string $secret): bool {
    $provided = preg_replace('/^sha256=/', '', $header);
    $expected = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $provided);
}

Responding

  • Return 2xx as soon as you have stored the event. Do slow work asynchronously.
  • Any non-2xx response, a timeout (10 s), or a connection error triggers a retry with exponential backoff (up to ~6 attempts over roughly an hour).
  • Deliveries are at-least-once and may arrive out of order. De-duplicate on (event, order_public_id, license_key) and always trust the newest license_expires_at / entitlements.

Security checklist

  • [ ] Reject any request whose signature does not verify.
  • [ ] Reject requests without HTTPS.
  • [ ] Rotate the signing secret if it may have leaked (update it in the dashboard, then deploy the new value).
  • [ ] Treat the webhook as a hint — confirm critical state changes with POST /v1/validate before permanently locking a customer out.