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": "ep_f47ac10b-...",
  "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
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.credited / balance.debited When the target is a participant:
{
  "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"}
}
When the target is a group:
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "group_id": "uuid",
  "asset_id": "uuid",
  "amount": "100.00",
  "bucket": "AVAILABLE",
  "reference_id": "auth_12345",
  "settle": {"held_amount": "80.00", "delta": "20.00"}
}
Exactly one of participant_id or group_id is present, depending on whether the target is a participant or a group.
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 When the target is a participant:
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "lot_count": 3
}
When the target is a group:
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "group_id": "uuid",
  "asset_id": "uuid",
  "lot_count": 3
}
Exactly one of participant_id or group_id is present, depending on whether the target is a participant or a group.
balance.held / balance.released When the target is a participant:
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "amount": "100.00"
}
When the target is a group:
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "group_id": "uuid",
  "asset_id": "uuid",
  "amount": "100.00"
}
Exactly one of participant_id or group_id is present, depending on whether the target is a participant or a group.
transfer.completed When the source is a participant:
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "source_participant_id": "uuid",
  "asset_id": "uuid",
  "recipients": [
    {
      "participant_id": "uuid",
      "amount": "50.00"
    }
  ]
}
When the source is a group:
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "source_group_id": "uuid",
  "asset_id": "uuid",
  "recipients": [
    {
      "group_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"
}

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 doesn’t return a 2xx response, 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 retry failed deliveries via the API.

Response Handling

Your ResponseScrip’s Behavior
2xxMarked DELIVERED
429 (rate limited)Retried on the backoff schedule. Does not count toward the endpoint’s failure rate. Rate-limited endpoints are retried without risk of auto-disable.
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.

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"}'
Set status back to ACTIVE to resume. Events that occurred while disabled are not retroactively delivered.

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 success rates per endpoint. Endpoints with sustained delivery failures are automatically set to DISABLED. Pending deliveries for a disabled endpoint are marked FAILED. 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. Rate-limited endpoints are retried on the normal backoff schedule without risk of auto-disable.

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), and last_error:
curl https://api.scrip.dev/v1/webhook-deliveries/{id} \
  -H "Authorization: Bearer $SCRIP_API_KEY"

Retry a Failed Delivery

Reset a FAILED delivery back to PENDING for a fresh set of 8 attempts:
curl -X POST https://api.scrip.dev/v1/webhook-deliveries/{id}/retry \
  -H "Authorization: Bearer $SCRIP_API_KEY"

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 using an outbox pattern. If the underlying transaction rolls back, no webhook is emitted. Each event is deduplicated by an internal idempotency key, so retried domain operations (like event reprocessing) don’t 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.