Webhooks Overview

Webhooks allow Spot to send partners real-time status updates for enrollments. Enrollments are processed asynchronously, so these callbacks ensure your system always reflects the current state. You are responsible for hosting and maintaining your webhook endpoint.

Spot provides the following endpoints for managing your webhook configuration. Note that you can also register your webhook URL in Spot's Partner Portal:

Webhook Payload

Spot will invoke the webhook any time an enrollment's status changes. For Refund Guarantee (CFAR) enrollments, the webhook can emit a status of ClaimReceived, which is the most important status to listen for and handle.

An example payload is below:

{
  "timestamp": "2025-11-21T21:33:08.933Z",
  "enrollment": {
    "id": "123456-9e7b-47e1-aaeb-ghjfkhjl",
    "transactionItemId": "abc-123",
    "status": "ClaimReceived",
    "resolvedByPartner": true,
    "startDate": "2026-01-17T23:00:00.000Z",
    "endDate": "2026-01-18T23:00:00.000Z"
  },
  "offer": {
    "sku": "123456-CFAR0101C0V"
  },
  "claim": {
    "amount": 200,
    "percent": 0.9,
    "currencyCode": "USD"
  }
}

Field descriptions:

  • timestamp: An ISO8601 date-time indicating when the enrollment most recently transitioned status.
  • enrollment.id: The enrollmentId returned from Spot when the enrollment was originally submitted.
  • enrollment.transactionItemId: The transactionItemId assigned to the enrollment during initial coverage creation. Only present when the status is ClaimReceived.
  • enrollment.status: The most recent status of the enrollment. Webhooks will only emit for the following status values:
    • Enrolled: The enrollment has been processed successfully.
    • Failed: Enrollment failed to process. This is rare - Spot monitors and retries automatically where possible. In some cases, partner coordination may be required to resolve underlying data issues.
    • ClaimReceived(CFAR only): The customer has activated their benefit and will not attend. Partners may use this signal to release inventory.
  • enrollment.resolvedByPartner: If true, the webhook was triggered by a partner-initiated resolve or cancel call. Only present when the status is ClaimReceived.
  • enrollment.startDate: The start date of Spot coverage. Only present if the status is not Failed.
  • enrollment.endDate: The end date of Spot coverage. Only present if the status is not Failed.
  • offer.sku: The offer SKU associated with this enrollment.
  • claim.amount: The refund amount in dollars the customer received from Spot. Only present when the status is ClaimReceived and there was a payout issued.
  • claim.percent: The % refund that was paid out to the customer, in decimals (ie. 0.5 = 50% refund, 1 = 100% refund). Only present when the status is ClaimReceived and there was a payout issued.
  • claim.currencyCode: The currency the refund was paid out in. Only present when the status is ClaimReceived and there was a payout issued.

Authenticating Webhook Payloads

Spot signs every webhook with HMAC-SHA256 and sends the signature in the X-Spot-Signature header. Your partner ID and HMAC secret are in the Partner Portal. Because only you and Spot know the secret, a matching signature shows the payload came from Spot and wasn't changed on the way. If the header is missing or the signature doesn't match, don't process the webhook.

How the signature is computed

  • Algorithm: HMAC-SHA256
  • Key: your partner ID and HMAC secret joined by a colon: <partnerId>:<secret>
  • Message: the raw request body, byte for byte. Don't parse and re-serialize the JSON; key order and
    escaping can change and the signature won't match.
  • Output: lowercase hex. Compare it with X-Spot-Signature using a constant-time function.

Test your implementation

With partner ID test-partner-id, secret test-secret, and this exact body:

{"timestamp": "2026-01-01T00:00:00.000Z", "enrollment": {"id":"00000000-0000-0000-0000-000000000000", "status": "Enrolled", "startDate": "2026-01-17T23:00:00.000Z", "endDate": "2026-01-18T23:00:00.000Z"}, "offer": {"sku": "TEST-SKU"}}

your code should produce:

e9c192f2dcccfc383183ef8852bcf72061d498ddb94a07082393ae70ed13c9d6

You can check it from a terminal:

printf '%s' '<body above>' | openssl dgst -sha256 -hmac 'test-partner-id:test-secret'

NodeJS

import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';

const HMAC_KEY = `${process.env.SPOT_PARTNER_ID}:${process.env.SPOT_WEBHOOK_SHARED_SECRET}`;

// express.raw keeps the body as the exact bytes Spot signed
app.post('/my/spot/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const digest = createHmac('sha256', HMAC_KEY).update(req.body).digest('hex');
  const signature = req.get('X-Spot-Signature') || '';

  const valid =
    signature.length === digest.length &&
    timingSafeEqual(Buffer.from(digest), Buffer.from(signature));

  if (!valid) return res.status(401).send('Not authorized');

  const payload = JSON.parse(req.body);
  // authenticated; process the payload here
  res.json({ message: 'OK' });
});

PHP

$hmacKey = getenv('SPOT_PARTNER_ID') . ':' . getenv('SPOT_WEBHOOK_SHARED_SECRET');

// the raw body, exactly as Spot sent it
$requestBody = file_get_contents('php://input');

$digest = hash_hmac('sha256', $requestBody, $hmacKey);
$signature = $_SERVER['HTTP_X_SPOT_SIGNATURE'] ?? '';

if (hash_equals($digest, $signature)) {
  $payload = json_decode($requestBody, true);
  // authenticated; process the payload here
} else {
  http_response_code(401);
}

Firewalls

To allow Spot's webhooks through a firewall, see Network & Allowlisting.