Skip to main content

Outbound Webhooks

Pay4Feedback POSTs to your endpoint when events happen in your account. Use them to sync feedback to your warehouse, trigger downstream automation, or ping your own notification systems.

Events

EventFires when
feedback_submittedParticipant submits feedback and it passes AI scoring
reward_approvedReviewer approves the reward (manual-review mode)
reward_rejectedReviewer rejects the reward
payout_completedTremendous confirms payout dispatch (or app-discount code issued)
campaign_activatedCampaign goes from PENDING_PAYMENT to ACTIVE after Stripe confirms payment

You can subscribe one endpoint to all events (*) or only the ones you care about.

Payload shape

All events share the same envelope:

{
"event": "reward_approved",
"timestamp": "2026-04-19T14:22:51Z",
"data": {
"responseId": "f3a7e1b2-...",
"rewardId": "bcd8e4f0-...",
"campaignId": "7e1c2d3a-...",
"amount": 12.50,
"currency": "EUR"
}
}

The data fields vary per event — see the list above. Always parse defensively: we may add fields without bumping a version.

Headers

Every request carries:

HeaderValue
Content-Typeapplication/json
User-AgentPay4Feedback-Webhook/1.0
X-Pay4Feedback-TimestampUnix seconds when the request was signed
X-Pay4Feedback-Signaturesha256=<hex> — HMAC-SHA256 of timestamp + "." + rawBody using your shared secret

Verifying the signature

If you set a signing secret when creating the webhook, verify every delivery before acting on it. The string being signed is the raw timestamp, a literal ., and the raw JSON body — concatenated, with no whitespace.

Node.js

import crypto from 'node:crypto';

function verify(req, secret) {
const ts = req.headers['x-pay4feedback-timestamp'];
const sig = req.headers['x-pay4feedback-signature']; // "sha256=abc..."
if (!ts || !sig) return false;

// Reject replays older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`) // req.rawBody must be the unparsed body
.digest('hex');

return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Python

import hmac, hashlib, time

def verify(timestamp: str, signature: str, raw_body: bytes, secret: str) -> bool:
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)

PHP

function verify(string $timestamp, string $signature, string $rawBody, string $secret): bool {
if (!$timestamp || !$signature) return false;
if (abs(time() - (int)$timestamp) > 300) return false;
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
Use the raw body

Don't re-serialise the parsed JSON — Node's JSON.stringify will reorder keys, flip spacing, or escape unicode differently and your HMAC will fail. Capture the raw request body before any JSON parser touches it.

Retry behaviour

If your endpoint doesn't return a 2xx within 10 seconds (or the request errors out), we retry with exponential backoff:

AttemptWait before retrying
1 (initial)
21 minute
35 minutes
430 minutes
52 hours
66 hours

After 5 retries the delivery is marked FAILED and dropped. 4xx responses count the same as 5xx — they're all retried — so respond 2xx as soon as you've durably enqueued the event, even if downstream processing fails.

We log every attempt. In Settings → Webhooks → Deliveries you can see the last 50 attempts per endpoint: status, HTTP code, error message, attempt count. Rows are pruned after 30 days.

Best practices

  • Respond fast. Enqueue the event, return 2xx, process asynchronously. A 10s timeout + any latency over the Atlantic eats your budget fast.
  • Be idempotent. A retry can arrive minutes after the first attempt; if both eventually succeed your endpoint will receive the same event twice. Key off data.responseId / data.rewardId / data.claimId.
  • Check the timestamp. Reject deliveries older than a few minutes — stops replay attacks with a leaked-then-captured payload.
  • Rotate the secret by creating a second webhook with the new secret, flipping your consumer to accept either, then deleting the old one.

Setup

  1. Settings → Webhooks → Add Endpoint.
  2. Paste your HTTPS URL. Non-HTTPS endpoints are refused — the signing secret isn't a substitute for transport encryption.
  3. Set a signing secret. Any opaque string ≥ 32 chars works; we recommend a cryptographically random one. Store it in your receiver's secret manager.
  4. Pick which events to subscribe to — default is "All events".
  5. Click Add Endpoint.
  6. Click Send test on the new row. A { "event": "test", ... } payload is POSTed immediately; the response code/error shows inline. If it's not 2xx, fix and re-test.
  7. Click Deliveries on the row to watch live events as they come in.

Troubleshooting

"Endpoint responded with HTTP 401 / 403"

Your endpoint is rejecting unauthenticated requests before it gets to the signature check. Add an allowlist exception for the webhook path, or verify the signature instead of using bearer-token auth for this path.

"Endpoint responded with HTTP 308"

Redirects aren't followed. Register the final URL directly (include www. or not, match the TLS host, no trailing slash mismatch).

Signature mismatch on your side

99% of the time: you re-serialised the JSON. Capture the raw body. In Express: app.use(express.json({ verify: (req, _, buf) => { req.rawBody = buf.toString(); } })).

Deliveries show SUCCESS but my downstream never sees them

You returned 2xx before your own queue accepted the event. Check the gap between the 2xx and your processing logs.

I need an event that isn't listed

Email sales@pay4feedback.com with your use case. We've added events for specific customer integrations before.