Webhooks push HTTP notifications to your server when key events happen: balance changes, redemptions, tier transitions, and more. Instead of polling the API for changes, you register a URL and Scrip delivers signed payloads the moment each event occurs.
Creating an Endpoint
Register a URL and specify which events you want to receive:
curl -X POST https://api.scrip.dev/v1/webhook-endpoints \
-H "Authorization: Bearer $SCRIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/scrip",
"description": "Production receiver",
"enabled_events": ["balance.credited", "redemption.completed"]
}'
The response includes a secret starting with whsec_. Store it immediately. It cannot be retrieved later. You’ll use it to verify signatures.
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"url": "https://example.com/webhooks/scrip",
"secret": "whsec_a1b2c3d4e5f6...",
"enabled_events": ["balance.credited", "redemption.completed"],
"status": "ACTIVE",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
Use "enabled_events": ["*"] to subscribe to all event types.
URLs must use HTTPS with a publicly resolvable hostname. Private IPs, localhost, .local, and .internal domains are rejected.
Event Types
| Event | Description |
|---|
balance.credited | Balance increased via rule action, API credit, or refund |
balance.debited | Balance decreased via rule action or API debit |
balance.expired | Lots expired and auto-forfeited |
balance.held | Balance moved from AVAILABLE to HELD |
balance.released | Balance moved from HELD back to AVAILABLE |
balance.voided | Provisionally issued HELD lots cancelled via void-hold |
redemption.completed | Amount or catalog item redeemed |
redemption.reversed | Redemption fully or partially reversed |
transfer.completed | Transfer between participants or groups completed |
event.completed | Event processing succeeded (all rules evaluated) |
event.failed | Event processing or validation failed |
participant.created | New participant enrolled |
participant.tier_changed | Tier level upgraded, downgraded, or removed |
program.funded | Program wallet funded |
program.burned | Program wallet balance burned |
Every delivery sends a JSON envelope:
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"type": "balance.credited",
"api_version": "2026-03-01",
"created_at": "2026-01-15T10:30:00Z",
"organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"journal_entry_id": "...",
"organization_id": "...",
"program_id": "...",
"participant_id": "...",
"asset_id": "...",
"amount": "100.00",
"bucket": "AVAILABLE"
}
}
| Field | Description |
|---|
id | Webhook event ID (UUID). Same across all endpoints receiving this event. |
type | The event type string. |
api_version | API version at time of emission. Currently 2026-03-01. |
created_at | When the event was created (RFC 3339). |
organization_id | Organization that owns the event. |
data | Event-specific payload. |
Event Payloads
Balance and transfer payloads identify the target with exactly one of participant_id or group_id, depending on whether the target is a participant or a group. The examples below show the participant form; the group form is identical with group_id in place of participant_id. For transfer.completed, the source field is source_participant_id or source_group_id.
balance.credited / balance.debited
{
"journal_entry_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"participant_id": "uuid",
"asset_id": "uuid",
"amount": "100.00",
"bucket": "AVAILABLE",
"reference_id": "auth_12345",
"settle": {"held_amount": "80.00", "delta": "20.00"}
}
reference_id is present when the credit or settle was correlated to a hold. settle is present only for settle operations (credit with reference_id to AVAILABLE) and contains held_amount (total previously held) and delta (settle minus held; positive = over-capture, negative = under-capture).
balance.expired
{
"journal_entry_id": "uuid",
"organization_id": "uuid",
"participant_id": "uuid",
"asset_id": "uuid",
"lot_count": 3
}
balance.held / balance.released
{
"journal_entry_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"participant_id": "uuid",
"asset_id": "uuid",
"amount": "100.00"
}
balance.voided
{
"journal_entry_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"participant_id": "uuid",
"asset_id": "uuid",
"amount": "100.00",
"reference_id": "auth_12345"
}
transfer.completed
{
"journal_entry_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"source_participant_id": "uuid",
"asset_id": "uuid",
"recipients": [
{
"participant_id": "uuid",
"amount": "50.00"
}
]
}
The source uses exactly one of source_participant_id or source_group_id. Each entry in recipients contains exactly one of participant_id or group_id, along with the amount transferred to that recipient. Source and recipient types may differ (e.g., a participant can transfer to a group).
redemption.completed (amount redemption)
{
"redemption_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"participant_id": "uuid",
"asset_id": "uuid",
"amount": "50.00"
}
redemption.completed (catalog item redemption)
{
"redemption_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"participant_id": "uuid",
"reward_catalog_item_id": "uuid"
}
redemption.reversed
{
"reversal_id": "uuid",
"redemption_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"participant_id": "uuid",
"asset_id": "uuid",
"amount": "50.00"
}
participant.created
{
"participant_id": "uuid",
"organization_id": "uuid",
"external_user_id": "user_123"
}
participant.tier_changed
When the target is a participant:
{
"participant_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"tier": "loyalty",
"level": "gold"
}
When the target is a group:
{
"group_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"tier": "loyalty",
"level": "gold"
}
When the target is a program:
{
"program_id": "uuid",
"organization_id": "uuid",
"tier": "loyalty",
"level": "gold"
}
level is null when a tier is removed (downgrade to base level).
Exactly one of participant_id, group_id, or program_id is present, depending on the target of the tier change. The SET_TIER rule action supports cross-targeting via the target field, so consumers should not assume this is always a participant.
program.funded / program.burned
{
"journal_entry_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"asset_id": "uuid",
"amount": "1000.00"
}
event.completed
{
"event_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid"
}
event.failed
{
"event_id": "uuid",
"organization_id": "uuid",
"program_id": "uuid",
"error": "description of what went wrong",
"error_code": "participant_suspended"
}
error_code is included when the failure has a classified machine-readable code (e.g. participant_suspended, program_inactive); it is omitted for unclassified failures. The same code is persisted on the event resource, so you can branch on failure type without string-matching error. Some early validation failures (e.g. an unparseable payload) omit program_id.
Signature Verification
Every delivery includes a Scrip-Signature header so you can verify it came from Scrip and wasn’t tampered with.
Scrip-Signature: t=1706090400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
| Component | Description |
|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | Hex-encoded HMAC-SHA256 signature |
Verification Steps
Extract components
Parse the t and v1 values from the Scrip-Signature header.
Construct signed payload
Concatenate the timestamp, a literal dot, and the raw request body: {t}.{raw_body}
Compute expected signature
Calculate HMAC-SHA256(your_endpoint_secret, signed_payload) and hex-encode the result.
Compare signatures
Use constant-time comparison. Reject the request if they don’t match.
Check timestamp
Reject if abs(now - t) exceeds your tolerance. We recommend 5 minutes.
Example (Python)
import hmac, hashlib, time
def verify_webhook(header, secret, body):
# Parse header
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
# Construct signed payload
signed_payload = f"{timestamp}.{body}"
# Compute expected signature
expected = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time compare
if not hmac.compare_digest(signature, expected):
raise ValueError("Invalid signature")
# Replay protection
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Timestamp too old")
Example (Node.js)
const crypto = require("crypto");
function verifyWebhook(header, secret, body) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2))
);
const { t: timestamp, v1: signature } = parts;
const signedPayload = `${timestamp}.${body}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex")
);
if (!valid) throw new Error("Invalid signature");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
throw new Error("Timestamp too old");
}
Retry Policy
If your endpoint has a retryable failure such as a 5xx response, network error, or timeout, Scrip retries with exponential backoff:
| Attempt | Delay | Cumulative |
|---|
| 1 | Immediate | 0 |
| 2 | 5 min | 5 min |
| 3 | 10 min | 15 min |
| 4 | 20 min | 35 min |
| 5 | 40 min | 1h 15m |
| 6 | 80 min | 2h 35m |
| 7 | 160 min | 5h 15m |
| 8 | 320 min | 10h 35m |
After 8 attempts (~10.5 hours), the delivery is marked FAILED. You can manually resend any terminal delivery to send the event again.
Response Handling
| Your Response | Scrip’s Behavior |
|---|
| 2xx | Marked DELIVERED |
| 429 (rate limited) | Retried at the Retry-After time when provided, otherwise after 30 seconds; parsed Retry-After values are capped at 1 hour. Does not count toward the endpoint’s failure rate. The endpoint is paused until rate_limited_until, while new matching events can continue to queue. |
| 4xx (except 429) | Marked FAILED immediately. No retry. |
| 5xx | Retried on the backoff schedule |
| Network error / timeout | Retried on the backoff schedule |
Return a 2xx quickly (within 30 seconds). Process the payload asynchronously if your handler needs more time. The worker enforces a 30-second timeout per delivery attempt.
Delivery Error Codes
Failed attempts include a stable error_code alongside the human-readable last_error. Use error_code for alerts and dashboards.
| Error Code | Meaning |
|---|
consumer_response_timeout | Your endpoint did not respond before the timeout |
connection_error | Scrip could not connect to your endpoint |
tls_error | TLS handshake or certificate validation failed |
consumer_4xx | Your endpoint returned a non-429 4xx response |
consumer_5xx | Your endpoint returned a 5xx response |
rate_limited | Your endpoint returned 429 and is paused for backoff |
endpoint_disabled | The endpoint was disabled and queued deliveries were failed out |
endpoint_archived | The endpoint was archived and queued deliveries were failed out |
endpoint_auto_disabled | The endpoint was auto-disabled because of sustained failures |
endpoint_backlog_exceeded | The endpoint was auto-disabled because its backlog was sustained and non-draining |
endpoint_no_longer_active | The endpoint was not active at delivery time |
marshal_error | Scrip could not serialize the webhook payload |
Managing Endpoints
Disable and Re-enable
Temporarily stop deliveries without deleting the endpoint:
curl -X PATCH https://api.scrip.dev/v1/webhook-endpoints/{id} \
-H "Authorization: Bearer $SCRIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "DISABLED"}'
Disabling an endpoint immediately fails queued or in-flight deliveries for that endpoint with error_code: "endpoint_disabled". Set status back to ACTIVE to receive future matching events. Events that occurred while the endpoint was disabled are not retroactively delivered.
If you disabled an endpoint temporarily and still want to deliver specific failed events, re-enable the endpoint and resend the terminal delivery records.
Rotate Secret
If a secret is compromised, rotate it immediately:
curl -X POST https://api.scrip.dev/v1/webhook-endpoints/{id}/rotate-secret \
-H "Authorization: Bearer $SCRIP_API_KEY"
The old secret is invalidated immediately. Update your verification code with the new secret before any in-flight deliveries arrive.
Delete
Deleting an endpoint archives it. It stops receiving deliveries and is removed from list results:
curl -X DELETE https://api.scrip.dev/v1/webhook-endpoints/{id} \
-H "Authorization: Bearer $SCRIP_API_KEY"
Endpoint Health
Scrip monitors delivery health per endpoint. A bad destination can be paused temporarily, then auto-disabled if it keeps failing or stops draining its backlog.
| Mechanism | Effect |
|---|
| Circuit breaker | Sustained failures pause delivery until circuit_broken_until |
| Rate-limit backoff | A 429 response pauses delivery until rate_limited_until |
| Failure auto-disable | Sustained failures after cooldown set the endpoint to DISABLED and fail queued deliveries |
| Backlog auto-disable | A sustained, non-draining backlog sets the endpoint to DISABLED and fails queued deliveries |
Use delivery stats to see whether an endpoint is healthy, paused, disabled, or building a backlog:
curl https://api.scrip.dev/v1/webhook-endpoints/delivery-stats \
-H "Authorization: Bearer $SCRIP_API_KEY"
Each row includes the 24-hour delivery rollup (success_24h, fail_24h, success_rate, degraded) plus current backlog and block fields: pending_count, oldest_pending_at, circuit_broken_until, rate_limited_until, blocked_reason, and blocked_until.
To re-enable an endpoint after resolving the underlying issue:
curl -X PATCH https://api.scrip.dev/v1/webhook-endpoints/{id} \
-H "Authorization: Bearer $SCRIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "ACTIVE"}'
HTTP 429 responses do not count toward the failure-rate breaker. However, if rate limiting creates a sustained, non-draining backlog, Scrip can auto-disable the endpoint with error_code: "endpoint_backlog_exceeded". Re-enable the endpoint and resend any failed deliveries you still need.
Debugging Deliveries
List Deliveries for an Endpoint
curl https://api.scrip.dev/v1/webhook-endpoints/{id}/deliveries?status=FAILED \
-H "Authorization: Bearer $SCRIP_API_KEY"
Inspect a Delivery
The detail endpoint includes last_response_status, last_response_body (truncated to 4 KB), last_error, error_code, and resend_seq:
curl https://api.scrip.dev/v1/webhook-deliveries/{id} \
-H "Authorization: Bearer $SCRIP_API_KEY"
Resend a Delivery
Resend issues a new delivery of the same event to the same endpoint: a fresh delivery with its own id and an incremented resend_seq. The source delivery is left untouched, so you keep a complete history of every attempt.
curl -X POST https://api.scrip.dev/v1/webhook-deliveries/{id}/resend \
-H "Authorization: Bearer $SCRIP_API_KEY"
The response is the newly created delivery (status PENDING), which you can poll like any other. Use resend to replay a webhook after fixing a bug in your handler, or to confirm your endpoint deduplicates correctly.
Only terminal deliveries can be resent, and the endpoint must be active:
| Condition | Response |
|---|
Source delivery is DELIVERED or FAILED | New delivery created, returned as PENDING |
Source delivery is PENDING or SENDING | 409 Conflict (not_resendable) |
| Endpoint is disabled or archived | 409 Conflict (endpoint_not_active) |
Best Practices
| Practice | Rationale |
|---|
| Verify signatures on every request | Prevents spoofed deliveries |
| Return 2xx quickly, process async | Avoids timeouts and unnecessary retries |
Use * sparingly | Subscribe only to events you need to reduce noise |
| Handle duplicates idempotently | Network retries can deliver the same event more than once. Use id to deduplicate. |
| Monitor failed deliveries | Check delivery status periodically or alert on consecutive failures |
Delivery Guarantees
Webhook events are created atomically with their domain operations. If the underlying transaction rolls back, no webhook is emitted. Retried domain operations (like event reprocessing) do not produce duplicate webhooks.
Delivery is at-least-once: a single event may be delivered more than once if your endpoint returns a 2xx but the acknowledgment is lost in transit. Design your handler to be idempotent using the envelope’s id field to detect duplicates.