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 verificationendpoint = client.create_webhook_endpoint({
"url": "https://your-app.com/webhooks/firsthand",
"events": ["submission.approved", "job.completed"],
})Event Types
| Event | Description |
|---|---|
submission.scored | A 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.approved | A submission scored 3+ stars and was auto-approved |
submission.rejected | A submission scored below 3 stars and was auto-rejected |
job.completed | All requested files have been collected |
job.cancelled | Job was cancelled by the buyer |
job.flagged | A worker flagged the job as unreasonable or unsafe. Useful for buyers to adjust descriptions or pricing. |
credits.purchased | A credit purchase succeeded and credits were added to the organization balance |
credits.low_balance | Organization balance dropped below a configurable threshold |
credits.auto_topped_up | Auto-top-up triggered — credits were automatically purchased and added to balance |
credits.expired | Unused credits hit their 12-month expiration and were removed from balance |
credits.expiring_soon | Credits 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', 200Delivery 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 24 hours |
After 6 failures, the event is marked as failed. You can manually replay it via the API.
Best Practices
- Always verify signatures — Never skip verification in production
- Return 200 quickly — Process events asynchronously to avoid timeouts
- Handle duplicates — Use the event
idas a deduplication key. Retries deliver the same event with the sameid. - Don’t assume ordering — Events may arrive out of order. Use
job.completed+ polling as a reconciliation checkpoint. - HTTPS only — Webhook endpoint URLs must use
https://. HTTP URLs are rejected with422. - Monitor failures — Check the webhook events list for delivery issues
- Rotate secrets — Rotate webhook secrets periodically via the API