GuidesWebhook Handling

Webhook Handling

Receive real-time notifications when submissions are scored and jobs are completed.

Setup

Register a webhook endpoint. URLs must use HTTPS — HTTP URLs are rejected with 422.

const endpoint = await client.createWebhookEndpoint({
  url: 'https://your-app.com/webhooks/firsthand',
  events: ['submission.approved', 'job.completed'],
});
// Save endpoint.secret for signature verification
endpoint = client.create_webhook_endpoint({
    "url": "https://your-app.com/webhooks/firsthand",
    "events": ["submission.approved", "job.completed"],
})

Event Types

EventDescription
submission.scoredA submission was scored by AI — fired for ALL submissions (approved + rejected). Includes full annotation payload, star rating, and dimensions. Use for analytics, active learning, and rejection monitoring.
submission.approvedA submission scored 3+ stars and was auto-approved
submission.rejectedA submission scored below 3 stars and was auto-rejected
job.completedAll requested files have been collected
job.cancelledJob was cancelled by the buyer
job.flaggedA worker flagged the job as unreasonable or unsafe. Useful for buyers to adjust descriptions or pricing.
credits.purchasedA credit purchase succeeded and credits were added to the organization balance
credits.low_balanceOrganization balance dropped below a configurable threshold
credits.auto_topped_upAuto-top-up triggered — credits were automatically purchased and added to balance
credits.expiredUnused credits hit their 12-month expiration and were removed from balance
credits.expiring_soonCredits are within 30 days of expiration — opportunity to spend or let expire

The full list of event types is also exported from @firsthandapi/sdk as WEBHOOK_EVENT_TYPES for type-safe event subscription.

Payload Format

All webhook events share a common envelope:

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "submission.approved",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T10:05:00Z",
  "data": { ... }
}

Use the id field as a deduplication key — the same event may be delivered multiple times due to retries.

submission.scored

Fired for every submission after AI scoring completes (both approved and rejected). Use for analytics, active learning, and rejection monitoring.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "submission.scored",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T10:05:00Z",
  "data": {
    "submission_id": "sub_01JQ...",
    "job_id": "job_01JQ...",
    "status": "approved",
    "ai_star_rating": 4,
    "dimensions": {
      "relevance": 5,
      "quality": 4,
      "instruction_compliance": 4
    },
    "annotations": {
      "type": "image",
      "objects": [{"label": "mailbox", "confidence": 0.95}],
      "scene": {"setting": "outdoor residential", "indoor": false}
    }
  }
}

submission.approved

Fired when a submission scores 3+ stars and is auto-approved.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "submission.approved",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T10:05:00Z",
  "data": {
    "job_id": "job_01JQ...",
    "submission_id": "sub_01JQ...",
    "worker_payout_cents": 40,
    "ai_star_rating": 4
  }
}

submission.rejected

Fired when a submission scores below 3 stars and is auto-rejected.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "submission.rejected",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T10:05:00Z",
  "data": {
    "job_id": "job_01JQ...",
    "submission_id": "sub_01JQ...",
    "ai_star_rating": 2,
    "ai_reasoning": "Photo is blurry and does not match the description."
  }
}

job.completed

Fired when all requested files have been collected and approved.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "job.completed",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T12:00:00Z",
  "data": {
    "job_id": "job_01JQ...",
    "files_approved": 50,
    "files_needed": 50,
    "total_worker_payout_cents": 2000
  }
}

job.cancelled

Fired when a buyer cancels a job.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "job.cancelled",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T13:00:00Z",
  "data": {
    "job_id": "job_01JQ...",
    "files_approved": 23,
    "files_needed": 50,
    "refunded_cents": 1350
  }
}

job.flagged

Fired when a worker flags a job as unreasonable (e.g., description conflicts with safety, price well below effort). Multiple flags from independent workers can signal a structural problem with the job.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "job.flagged",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T14:00:00Z",
  "data": {
    "job_id": "job_01JQ...",
    "flag_id": "flag_01JQ...",
    "reason": "price_too_low",
    "worker_note": "Task requires 15+ minutes of capture but pays $0.15"
  }
}

credits.purchased

Fired after a successful Stripe checkout settles and credits are added to the organization balance.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "credits.purchased",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T09:00:00Z",
  "data": {
    "transaction_id": "crtx_01JQ...",
    "amount_cents": 10000,
    "bonus_cents": 0,
    "new_balance_cents": 22500,
    "stripe_charge_id": "ch_..."
  }
}

credits.low_balance

Fired when the balance drops below the configured low_balance_threshold_cents (default: $20). Deduplicated per-day so you don’t get spammed.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "credits.low_balance",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T11:30:00Z",
  "data": {
    "balance_cents": 1850,
    "threshold_cents": 2000,
    "auto_top_up_enabled": false
  }
}

credits.auto_topped_up

Fired when auto-top-up is enabled and the balance crossed the trigger. Includes the transaction reference so you can reconcile.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "credits.auto_topped_up",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T12:00:00Z",
  "data": {
    "transaction_id": "crtx_01JQ...",
    "amount_cents": 5000,
    "new_balance_cents": 6200,
    "triggered_by": "balance_below_threshold"
  }
}

credits.expiring_soon

Fired ~30 days before a credit batch expires. One event per expiring batch. Use to prompt end-of-quarter spending or auto-renewal.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "credits.expiring_soon",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T08:00:00Z",
  "data": {
    "expiring_cents": 2500,
    "expires_at": "2026-04-15T00:00:00Z",
    "days_remaining": 30
  }
}

credits.expired

Fired when a credit batch hits its 12-month expiration and is deducted from the balance.

{
  "id": "evt_01JQ...",
  "object": "event",
  "type": "credits.expired",
  "api_version": "2026-03-18",
  "created_at": "2026-03-15T00:00:00Z",
  "data": {
    "transaction_id": "crtx_01JQ...",
    "expired_cents": 500,
    "new_balance_cents": 1700
  }
}

Signature Verification

Always verify webhook signatures to ensure authenticity.

import { verifyWebhookSignature } from '@firsthandapi/sdk';
 
app.post('/webhooks/firsthand', (req, res) => {
  try {
    verifyWebhookSignature(
      req.rawBody,
      req.headers['x-firsthand-signature'],
      process.env.WEBHOOK_SECRET,
    );
  } catch (e) {
    return res.status(400).send('Invalid signature');
  }
 
  const event = req.body;
  switch (event.type) {
    case 'submission.approved':
      handleSubmissionApproved(event.data);
      break;
    case 'job.completed':
      handleJobCompleted(event.data);
      break;
  }
 
  res.status(200).send('OK');
});
from firsthandapi import verify_webhook_signature
 
@app.route('/webhooks/firsthand', methods=['POST'])
def handle_webhook():
    try:
        verify_webhook_signature(
            request.data,
            request.headers['X-FirstHand-Signature'],
            WEBHOOK_SECRET,
        )
    except ValueError:
        return 'Invalid signature', 400
 
    event = request.json
    if event['type'] == 'submission.approved':
        handle_submission_approved(event['data'])
    elif event['type'] == 'job.completed':
        handle_job_completed(event['data'])
 
    return 'OK', 200

Delivery Ordering

Webhook events are not guaranteed to arrive in order. For example, job.completed may arrive before the final submission.approved event. Design your handler to be order-independent.

Recommended pattern: When you receive job.completed, poll the files endpoint to reconcile your local state:

case 'job.completed':
  // Don't rely solely on individual submission.approved events
  const files = await client.getJobFiles(event.data.job_id);
  await syncFilesToDatabase(files.data);
  break;

Retry Policy

Failed deliveries (non-2xx responses) are retried up to 6 times with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours
624 hours

After 6 failures, the event is marked as failed. You can manually replay it via the API.

Best Practices

  1. Always verify signatures — Never skip verification in production
  2. Return 200 quickly — Process events asynchronously to avoid timeouts
  3. Handle duplicates — Use the event id as a deduplication key. Retries deliver the same event with the same id.
  4. Don’t assume ordering — Events may arrive out of order. Use job.completed + polling as a reconciliation checkpoint.
  5. HTTPS only — Webhook endpoint URLs must use https://. HTTP URLs are rejected with 422.
  6. Monitor failures — Check the webhook events list for delivery issues
  7. Rotate secrets — Rotate webhook secrets periodically via the API