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
| Event | Fires when |
|---|---|
feedback_submitted | Participant submits feedback and it passes AI scoring |
reward_approved | Reviewer approves the reward (manual-review mode) |
reward_rejected | Reviewer rejects the reward |
payout_completed | Tremendous confirms payout dispatch (or app-discount code issued) |
campaign_activated | Campaign 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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Pay4Feedback-Webhook/1.0 |
X-Pay4Feedback-Timestamp | Unix seconds when the request was signed |
X-Pay4Feedback-Signature | sha256=<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);
}
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:
| Attempt | Wait before retrying |
|---|---|
| 1 (initial) | — |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 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
- Settings → Webhooks → Add Endpoint.
- Paste your HTTPS URL. Non-HTTPS endpoints are refused — the signing secret isn't a substitute for transport encryption.
- Set a signing secret. Any opaque string ≥ 32 chars works; we recommend a cryptographically random one. Store it in your receiver's secret manager.
- Pick which events to subscribe to — default is "All events".
- Click Add Endpoint.
- 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. - 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.
Related
- Slack notifications — if you want a person pinged, not a system
- Microsoft Teams notifications — same, for Teams shops
- API & Integrations — roadmap for the public REST API