Skip to main content
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

EventDescription
balance.creditedBalance increased via rule action, API credit, or refund
balance.debitedBalance decreased via rule action or API debit
balance.expiredLots expired and auto-forfeited
balance.heldBalance moved from AVAILABLE to HELD
balance.releasedBalance moved from HELD back to AVAILABLE
balance.voidedProvisionally issued HELD lots cancelled via void-hold
redemption.completedAmount or catalog item redeemed
redemption.reversedRedemption fully or partially reversed
transfer.completedTransfer between participants or groups completed
event.completedEvent processing succeeded (all rules evaluated)
event.failedEvent processing or validation failed
participant.createdNew participant enrolled
participant.tier_changedTier level upgraded, downgraded, or removed
program.fundedProgram wallet funded
program.burnedProgram wallet balance burned

Payload Format

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"
  }
}
FieldDescription
idWebhook event ID (UUID). Same across all endpoints receiving this event.
typeThe event type string.
api_versionAPI version at time of emission. Currently 2026-03-01.
created_atWhen the event was created (RFC 3339).
organization_idOrganization that owns the event.
dataEvent-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.

Header Format

Scrip-Signature: t=1706090400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
ComponentDescription
tUnix timestamp (seconds) when the signature was generated
v1Hex-encoded HMAC-SHA256 signature

Verification Steps

1

Extract components

Parse the t and v1 values from the Scrip-Signature header.
2

Construct signed payload

Concatenate the timestamp, a literal dot, and the raw request body: {t}.{raw_body}
3

Compute expected signature

Calculate HMAC-SHA256(your_endpoint_secret, signed_payload) and hex-encode the result.
4

Compare signatures

Use constant-time comparison. Reject the request if they don’t match.
5

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:
AttemptDelayCumulative
1Immediate0
25 min5 min
310 min15 min
420 min35 min
540 min1h 15m
680 min2h 35m
7160 min5h 15m
8320 min10h 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 ResponseScrip’s Behavior
2xxMarked 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.
5xxRetried on the backoff schedule
Network error / timeoutRetried 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 CodeMeaning
consumer_response_timeoutYour endpoint did not respond before the timeout
connection_errorScrip could not connect to your endpoint
tls_errorTLS handshake or certificate validation failed
consumer_4xxYour endpoint returned a non-429 4xx response
consumer_5xxYour endpoint returned a 5xx response
rate_limitedYour endpoint returned 429 and is paused for backoff
endpoint_disabledThe endpoint was disabled and queued deliveries were failed out
endpoint_archivedThe endpoint was archived and queued deliveries were failed out
endpoint_auto_disabledThe endpoint was auto-disabled because of sustained failures
endpoint_backlog_exceededThe endpoint was auto-disabled because its backlog was sustained and non-draining
endpoint_no_longer_activeThe endpoint was not active at delivery time
marshal_errorScrip 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.
MechanismEffect
Circuit breakerSustained failures pause delivery until circuit_broken_until
Rate-limit backoffA 429 response pauses delivery until rate_limited_until
Failure auto-disableSustained failures after cooldown set the endpoint to DISABLED and fail queued deliveries
Backlog auto-disableA 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:
ConditionResponse
Source delivery is DELIVERED or FAILEDNew delivery created, returned as PENDING
Source delivery is PENDING or SENDING409 Conflict (not_resendable)
Endpoint is disabled or archived409 Conflict (endpoint_not_active)

Best Practices

PracticeRationale
Verify signatures on every requestPrevents spoofed deliveries
Return 2xx quickly, process asyncAvoids timeouts and unnecessary retries
Use * sparinglySubscribe only to events you need to reduce noise
Handle duplicates idempotentlyNetwork retries can deliver the same event more than once. Use id to deduplicate.
Monitor failed deliveriesCheck 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.