Gareth Wilson Gareth Wilson

Guide to Affonso Webhooks: Features and Best Practices

Published


Affonso webhooks tell your product when something happens in your affiliate program: a partner signs up, a referred visitor becomes a lead or a paying customer, a commission is approved or paid, or a payout fails. Affonso is an affiliate platform for SaaS companies, and webhooks are how that activity reaches your own billing, CRM, analytics and partner portal without polling the Affonso API.

This guide covers how Affonso endpoints are created and signed, the events and payloads you will receive, how failed deliveries are retried, and the practices that keep an Affonso receiver reliable.

What are Affonso webhooks?

An Affonso webhook endpoint is an HTTPS URL plus the events it should receive, either all of them or a selection. When one of those events happens in your program, Affonso sends a JSON POST to the URL and expects a 2xx within 10 seconds.

Every request carries two headers. X-Affonso-Timestamp is the Unix time in seconds when the request was signed, and X-Affonso-Signature is a lowercase hex HMAC-SHA256 of the timestamp, a full stop and the raw body. The key is the endpoint's signing secret. That secret looks like hex, but Affonso is explicit that you use it "as-is as a string key; do not hex-decode it".

Affonso webhook features

FeatureDetails
ConfigurationDashboard: Settings > Webhooks > Add Endpoint
Endpoint URLHTTPS only. HTTP endpoints are not supported
SubscriptionAll events, or specific events
Payload{ "id", "type", "createdAt", "data" }
Signature headerX-Affonso-Signature, lowercase hex, no prefix
Timestamp headerX-Affonso-Timestamp, Unix seconds
SchemeHMAC-SHA256, hex digest
Signed content{X-Affonso-Timestamp}.{raw body}
Secret64-character hex string per endpoint, used as a UTF-8 string, shown once
Response window10 seconds
RetriesExponential backoff for up to 48 hours, 11 attempts in all, then marked as failed
TestingSend Test Event in the dashboard

Affonso publishes no source IP list and no exact backoff intervals, and it sends no delivery ID header. The event id in the body is the deduplication key Affonso recommends.

Common events

Affonso groups its events by object. The type field in the body names the event.

AreaEventsFires when
Affiliatesaffiliate.created, affiliate.confirmed, affiliate.updated, affiliate.deletedA partner signs up or is created, confirms their email, changes details or status, or is removed
Referralsreferral.updated, referral.lead, referral.converted, referral.deletedA referral is created or changes, becomes a lead, converts to a paying customer, or is deleted
Transactionstransaction.created, transaction.updated, transaction.approved, transaction.paid, transaction.rejected, transaction.deletedA sale and its commission are recorded, changed, approved, paid, declined or refunded, or deleted
Payoutspayout.created, payout.updated, payout.paid, payout.failed, payout.deletedA payout to an affiliate is created, changed, completed, fails or is deleted
Couponscoupon.created, coupon.deletedA coupon code is created for an affiliate or deleted

referral.updated, transaction.updated and payout.updated are what Affonso calls universal events: they fire on any change, so you can subscribe to one of them instead of every specific event for that object. Refunds have no event of their own. A full refund arrives as transaction.rejected and a partial refund as transaction.updated.

Here is Affonso's referral.converted example:

{
  "id": "evt_abc123",
  "type": "referral.converted",
  "createdAt": "2024-01-15T10:30:00.000Z",
  "data": {
    "referralId": "ref_xyz789",
    "affiliateId": "aff_abc123",
    "affiliateProgramId": "prog_def456",
    "trackingId": "john-doe",
    "status": "CUSTOMER",
    "email": "customer@example.com",
    "customerId": "cus_stripe123",
    "ip": "192.168.1.1",
    "referrer": "https://blog.example.com/review"
  }
}
  • id is the event ID, prefixed evt_. Use it for idempotency.
  • type is the event name. Route on it.
  • createdAt is an ISO 8601 timestamp of when the event happened.
  • data holds the object. Referral status runs through CLICK, LEAD, TRIALING, CUSTOMER, ACTIVE, CANCELED and REJECTED.

Transaction events carry both a sale status (OPEN, COMPLETE, TRIALING, FAILED, REFUNDED, PARTIAL_REFUNDED) and a commission status (PENDING, PENDING_MANUAL_APPROVAL, READY_FOR_PAYMENT, PROCESSING_PAYOUT, PAID, DECLINED, REFUNDED, DISPUTE). When a conversion does not create an affiliate earning, the commission fields can be null. Field naming is not uniform either: referral and transaction data use camelCase keys such as affiliateId, while coupon data uses snake_case such as affiliate_id.

Setting up Affonso webhooks

  1. In the Affonso dashboard, go to Settings > Webhooks.
  2. Click Add Endpoint and enter your HTTPS URL, for example https://example.com/webhooks/affonso.
  3. Choose all events, or only the ones you need.
  4. Save, then copy the Signing Secret straight away. Affonso shows it once. Store it server-side, for example as AFFONSO_WEBHOOK_SECRET.
  5. Click Send Test Event to check that your endpoint answers with a 2xx.

If you use Hookdeck, create a source with the Affonso source type, paste the signing secret into it exactly as copied, and use the Hookdeck source URL as the endpoint URL in Affonso. The source accepts POST only, which is the method Affonso uses.

Securing Affonso webhooks

To verify a delivery, read X-Affonso-Timestamp and X-Affonso-Signature. Build the signed content from the timestamp header exactly as received, a . and the raw request body. Compute an HMAC-SHA256 with the secret string as the key, encode it as hex and compare it with the header in constant time. The header is a bare digest, so there is nothing to strip or split.

Affonso's own samples also reject a timestamp more than five minutes from the current time. In the code below that check is optional and off by default, for a reason covered in the next section.

const crypto = require('crypto');
const express = require('express');

function verifyAffonsoSignature(rawBody, timestamp, signature, secret, toleranceSeconds = 0) {
  if (!timestamp || !signature || !secret) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestamp)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  }

  // The secret is used as a string. Do not hex-decode it.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp, 'latin1')
    .update('.')
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(signature.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/affonso', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyAffonsoSignature(
    req.body,
    req.get('x-affonso-timestamp'),
    req.get('x-affonso-signature'),
    process.env.AFFONSO_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, then route on event.type.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re
import time

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")


def verify_affonso_signature(raw_body: bytes, timestamp, signature, secret: str,
                             tolerance_seconds: int = 0) -> bool:
    if not timestamp or not signature or not secret:
        return False
    if not HEX_64.fullmatch(signature):
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0:
        if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
            return False

    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_affonso_signature(raw_body, request.headers.get("x-affonso-timestamp"),
#                          request.headers.get("x-affonso-signature"),
#                          os.environ["AFFONSO_WEBHOOK_SECRET"])

Hookdeck's Affonso source type checks the same signature: HMAC-SHA256 over {X-Affonso-Timestamp}.{body}, hex, keyed with the secret string. It applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.

Make Affonso webhooks production-ready. Hookdeck Event Gateway verifies the X-Affonso-Signature header on every delivery, stores each affiliate, referral and commission event, and delivers it to your service with retries you control.

Affonso webhook limitations and pain points

A secret that looks like hex

The Problem: Every delivery fails verification, even though the secret was copied correctly.

Why It Happens: The signing secret is 64 hex characters, which invites code that decodes it to 32 bytes before using it as a key. Affonso uses the string itself. Decoding it gives a different key, and the result is a valid-looking digest that never matches.

Workarounds:

  • Pass the secret straight to the HMAC function as a string, with no prefix and no decoding.
  • Keep the raw body as bytes until verification has passed.

How Hookdeck Can Help: Hookdeck's Affonso source uses the pasted secret as a string, so the key handling sits in one place rather than in each service that receives Affonso events.

The five-minute window and retries

The Problem: A delivery that Affonso retries hours later, or that you replay from Hookdeck, is rejected by your own code even though its signature is valid.

Why It Happens: Affonso's samples reject anything with a timestamp more than 300 seconds old, but Affonso keeps retrying a failed event for up to 48 hours. A retry or replay that carries the original X-Affonso-Timestamp falls outside a five-minute window, and Hookdeck retries and replays resend the original headers.

Workarounds:

  • Leave the timestamp check off and rely on idempotency by event id instead.
  • If you turn it on, choose a window longer than the longest delay you allow between the original send and a retry or replay.

How Hookdeck Can Help: Hookdeck applies no timestamp tolerance, so retried and replayed deliveries still pass verification at the source.

Eleven attempts, then nothing

The Problem: Events from a long outage are marked as failed and never arrive.

Why It Happens: A delivery fails on a non-2xx or after 10 seconds without a response. Affonso retries with exponential backoff for up to 48 hours, 11 attempts in all, and then gives up. The intervals aren't published.

Workarounds:

  • Answer with a 2xx as soon as the signature checks out, and do the work afterwards.
  • After an outage, reconcile commissions and payouts against the Affonso API rather than assuming every event arrived.

How Hookdeck Can Help: Hookdeck accepts each delivery as it arrives and stores it, then delivers to your service on a retry schedule you choose, so your downtime doesn't use up Affonso's attempts.

Refunds hide inside other events

The Problem: A handler that waits for a refund event never sees one, and commissions on refunded sales stay approved in your system.

Why It Happens: Affonso has no refund event. A full refund is transaction.rejected, and a partial refund is transaction.updated with a sale status of PARTIAL_REFUNDED.

Workarounds:

  • Subscribe to transaction.rejected and transaction.updated.
  • Read the sale and commission statuses on every transaction event instead of trusting the event name alone.

How Hookdeck Can Help: Hookdeck filters can route transaction events on data fields such as the sale status, so refund handling can go to its own destination.

Best practices

Verify against the raw body

The signature covers the exact bytes Affonso sent. In Express, use express.raw({ type: '*/*' }) on the webhook route. In a Next.js route handler, use Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body(). Parse JSON only after verification passes.

Deduplicate on the event id

Retries carry the same event, so store each id once it is processed and skip repeats. Affonso's own troubleshooting advice is to "implement idempotency using the event id field". See our guide to webhook idempotency.

Acknowledge fast and process asynchronously

Affonso waits 10 seconds. Verify, persist the event, return 200, and update commissions or notify partners afterwards. See why to process webhooks asynchronously.

Start with the events you need

Affonso suggests starting with specific events and adding more later. The universal *.updated events cover most changes for their object, so you may not need every specific event alongside them.

Handle both naming styles

Read referral and transaction fields in camelCase and coupon fields in snake_case, and allow commission fields to be null.

Conclusion

Affonso signs every delivery with a hex HMAC-SHA256 of the timestamp and raw body, keyed with a secret that must be used as a string, not decoded. Verify against the raw bytes, deduplicate on the event id, watch transaction.rejected and transaction.updated for refunds, and answer within 10 seconds so Affonso's 11 attempts are never used up.

Hookdeck Event Gateway verifies every Affonso signature and holds each event until your service has processed it, so a commission or payout update isn't lost to an outage on your side.

Get started with Hookdeck for free and handle Affonso webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.