Skip to content

Receive webhooks

Create an endpoint (Pro: up to 5):

Create an endpoint (Pro: up to 5):

curl -X POST https://api.urlcrop.com/v1/webhooks \
  -H "Authorization: Bearer $URLCROP_API_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://crm.brand.in/hooks/urlcrop", "events": ["link.created", "link.safety_flagged", "domain.ssl_active", "usage.threshold_reached"] }'

Copy the secret (whsec_…) from the response — it is shown once. Each delivery is a POST with JSON like:

{
  "id": "evt_01JB4P2Q7R8S9T0UVWXYZ012345",
  "object": "event",
  "type": "link.created",
  "api_version": "v1",
  "is_test": false,
  "created_at": "2026-12-05T10:15:30.000Z",
  "workspace_id": "ws_01JB4M0A1B2C3D4E5F6G7H8J9K",
  "data": { "object": { "id": "lnk_01JB4N8ZK3Q9WXYZ0123456789", "object": "link", "short_url": "https://go.brand.in/diwali" } }
}

and the header URLCrop-Signature: t=1764930930,v1=5f8d2c…. Verify it before trusting the payload: compute HMAC-SHA256(secret, "<t>.<raw body>"), compare in constant time with any v1 value, and reject timestamps older than 5 minutes.

Node:

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyURLCropSignature(rawBody: string, header: string, secret: string): boolean {
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s, 'hex'), Buffer.from(expected, 'hex')));
}

// Express: app.post('/hooks/urlcrop', express.raw({ type: 'application/json' }), (req, res) => { … })
// or with the SDK: const event = uc.webhooks.constructEvent(rawBody, req.headers['urlcrop-signature'], secret);

PHP:

<?php
function verifyUrlcropSignature(string $rawBody, string $header, string $secret): bool {
    $t = null; $sigs = [];
    foreach (explode(',', $header) as $pair) {
        [$k, $v] = array_map('trim', explode('=', $pair, 2));
        if ($k === 't') { $t = (int) $v; }
        if ($k === 'v1') { $sigs[] = $v; }
    }
    if ($t === null || abs(time() - $t) > 300 || !$sigs) { return false; }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { return true; } }
    return false;
}

$raw = file_get_contents('php://input');
if (!verifyUrlcropSignature($raw, $_SERVER['HTTP_URLCROP_SIGNATURE'] ?? '', getenv('URLCROP_WEBHOOK_SECRET'))) {
    http_response_code(400); exit;
}
$event = json_decode($raw, true);

Respond with any 2xx within 10 seconds. Failed deliveries are retried 6 more times over about 24 hours; an endpoint that fails continuously for 7 days is disabled and you are emailed. Use id (the event id) to ignore duplicates. Deliveries, responses and manual retries are at GET /v1/webhooks/{id}/deliveries, and POST /v1/webhooks/{id}/test sends a ping.

Event types (Phase 1): link.created, link.updated, link.deleted, link.safety_flagged, link.health_changed, link.expired, qr.created, qr.updated, qr.deleted, page.published, page.unpublished, domain.verified, domain.ssl_active, domain.verification_failed, member.joined, member.removed, usage.threshold_reached (80 % and 100 %), subscription.updated, invoice.paid, payment.failed, export.completed. Phase 2: conversion.recorded, conversion.rejected, job.completed. Phase 3: partner.commission_created, partner.commission_approved, payout.completed, payout.failed.

Destination health and UTM fields. link.health_changed is available: it fires when a link's destination turns unhealthy after three failed checks or returns to healthy after three good ones, and carries the link with its health summary. GET /v1/links/{id}/health returns status (healthy, unhealthy or unknown), http_status, last_checked_at, consecutive_failures and the last 30 checks; POST /v1/links/{id}/health/check queues a re-check (202, 10 per hour per link); GET /v1/links accepts health=healthy|unhealthy|unknown. Link create and update accept utm and utm_template_id; templates live at /v1/utm-templates, and {{slug}}, {{domain}} and {{campaign}} are the only placeholders.