Webhooks
Signed, retried webhook deliveries for link, click, conversion, and billing events.
Public Url webhooks push real-time events to your own endpoints. Subscribe to link, click, conversion, domain, campaign, report, and billing events — every delivery is signed, retried with exponential backoff, and logged for auditing.
Base URL
https://api.publicurl.in/v1/webhooksAll endpoints require authentication via session cookie or API key. Admin-level endpoints require the owner or admin role.
Event Catalog
Subscribe to individual event types or use "*" to receive all events.
| Event | Description | Volume |
|---|---|---|
link.created | New short link created | Low |
link.updated | Link renamed, redirected, archived, or metadata changed | Low |
link.deleted | Link permanently removed | Low |
domain.verified | Branded domain passes DNS verification | Low |
domain.failed | Domain verification attempt fails | Low |
click.received | Every click for a link in the workspace | High |
conversion.received | Server-to-server conversion event lands | Medium |
campaign.completed | Campaign marked complete | Low |
report.generated | Scheduled or on-demand report export finishes | Low |
billing.subscription.updated | Workspace subscription changes plan or renews | Low |
Endpoints
List endpoints
GET /v1/webhooks/endpointsReturns all webhook endpoints for the active workspace. The secret field is redacted.
Response 200 OK
{
"data": [
{
"id": "wh_abc123",
"workspaceId": "wrk_xyz",
"url": "https://hooks.example.com/events",
"description": "Production event receiver",
"events": ["link.created", "link.updated", "click.received"],
"status": "active",
"failureCount": 0,
"lastFailureAt": null,
"lastSuccessAt": "2026-08-03T12:00:00Z",
"createdBy": "usr_abc",
"createdAt": "2026-07-01T00:00:00Z",
"updatedAt": "2026-08-03T12:00:00Z"
}
]
}Create endpoint
POST /v1/webhooks/endpointsRequest body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | HTTPS endpoint that receives events |
events | string[] | yes | Event types to subscribe to (use ["*"] for all) |
description | string | no | Max 500 characters |
status | string | no | "active" (default) or "paused" |
Response 201 Created
Returns the created endpoint including the auto-generated secret. Store the secret — it is only returned on creation and via the dedicated secret endpoint.
{
"data": {
"id": "wh_abc123",
"workspaceId": "wrk_xyz",
"url": "https://hooks.example.com/events",
"events": ["link.created", "click.received"],
"status": "active",
"secret": "whsec_5f4dcc3b5aa765d61d8327deb882cf99",
"createdAt": "2026-08-03T12:00:00Z",
"updatedAt": "2026-08-03T12:00:00Z"
}
}Get endpoint
GET /v1/webhooks/endpoints/:idReturns a single endpoint. The secret field is redacted.
Get signing secret
GET /v1/webhooks/endpoints/:id/secretReturns the endpoint's current signing secret. Requires admin role.
Response 200 OK
{
"data": {
"id": "wh_abc123",
"secret": "whsec_5f4dcc3b5aa765d61d8327deb882cf99"
}
}Update endpoint
PATCH /v1/webhooks/endpoints/:idRequest body (all fields optional)
| Field | Type | Description |
|---|---|---|
url | string | New HTTPS endpoint URL |
events | string[] | Replace subscribed event types |
description | string or null | Update or clear description |
status | string | "active" or "paused" |
Rotate secret
POST /v1/webhooks/endpoints/:id/secret/rotateGenerates a new signing secret. The old secret is immediately invalidated — update your consumer code before calling this.
Response 200 OK
{
"data": {
"id": "wh_abc123",
"secret": "whsec_newly_generated_secret"
}
}Send test event
POST /v1/webhooks/endpoints/:id/testSends a test payload to the endpoint. Useful for verifying your consumer is set up correctly.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
eventType | string | no | Defaults to link.created |
Response 202 Accepted
{
"data": {
"id": "whd_test_1722681600000",
"status": "pending",
"eventType": "link.created",
"createdAt": "2026-08-03T12:00:00Z"
}
}Delete endpoint
DELETE /v1/webhooks/endpoints/:idPermanently removes the endpoint and all its delivery logs. Response 204 No Content.
List deliveries
GET /v1/webhooks/deliveriesQuery parameters
| Parameter | Type | Description |
|---|---|---|
endpointId | string | Filter by endpoint |
status | string | pending, processing, completed, failed, dead_letter |
limit | number | Max 100, default 50 |
offset | number | Default 0 |
Response 200 OK
{
"data": [
{
"id": "whd_del_001",
"workspaceId": "wrk_xyz",
"endpointId": "wh_abc123",
"eventType": "link.created",
"eventId": "evt_001",
"status": "completed",
"attempts": 1,
"maxAttempts": 5,
"responseStatus": 200,
"durationMs": 145,
"createdAt": "2026-08-03T12:00:00Z",
"completedAt": "2026-08-03T12:00:01Z"
}
],
"meta": { "total": 42 }
}Get delivery
GET /v1/webhooks/deliveries/:deliveryIdReturns full delivery details including response body (truncated to 4000 chars), response headers, and the original event payload.
Retry delivery
POST /v1/webhooks/deliveries/:deliveryId/retryRe-queues a failed or dead-letter delivery for another attempt. Requires admin role.
Response 202 Accepted
List event catalog
GET /v1/webhooks/eventsReturns all supported event types with labels and descriptions.
Response 200 OK
{
"data": {
"events": [
{
"type": "link.created",
"label": "Link created",
"description": "New short link created in the workspace"
}
]
}
}Manual dispatch
POST /v1/webhooks/dispatchManually dispatches an event to all active endpoints subscribed to the given type. Useful for testing or re-processing.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | Event type from the catalog |
payload | object | no | Custom data sent to subscribers |
Response 202 Accepted
{
"data": { "dispatched": 3 }
}Delivery Format
Every delivery is an HTTP POST to your endpoint URL with the following structure:
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | PublicUrl-Webhooks/1.0 |
x-public-url-event | Event type (e.g. link.created) |
x-public-url-signature | t=<unix_timestamp>,v1=<hmac_hex> |
x-public-url-timestamp | Unix timestamp (seconds) |
x-public-url-event-id | Upstream event identifier |
Body
{
"id": "whd_del_001",
"type": "link.created",
"workspaceId": "wrk_xyz",
"endpointId": "wh_abc123",
"attempt": 1,
"createdAt": "2026-08-03T12:00:00.000Z",
"data": {
"id": "lnk_abc",
"slug": "my-link",
"destinationUrl": "https://example.com",
"workspaceId": "wrk_xyz"
}
}The data field contains the original event payload. The shape varies by event type.
Signature Verification
Every delivery includes an x-public-url-signature header. Verify it to confirm the payload came from Public Url and hasn't been tampered with.
Algorithm
- Extract the timestamp (
t) and signature (v1) from the header:t=1722681600,v1=5f4dcc3b5aa765d61d8327deb882cf99 - Build the signed payload string:
t.<request_body>(the literalt.followed by the raw JSON body). - Compute HMAC-SHA256 of that string using your endpoint's secret.
- Compare the computed signature with
v1using constant-time comparison. - (Optional) Reject payloads where
tis more than 5 minutes old.
Node.js example
import { createHmac, timingSafeEqual } from "node:crypto"
function verifyWebhookSignature(
body: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300
): boolean {
const parts = signatureHeader.split(",")
const ts = parts.find((p) => p.startsWith("t="))?.slice(2)
const sig = parts.find((p) => p.startsWith("v1="))?.slice(3)
if (!ts || !sig) return false
const now = Math.floor(Date.now() / 1000)
if (now - Number(ts) > toleranceSeconds) return false
const payload = `t.${ts}.${body}`
const computed = createHmac("sha256", secret).update(payload).digest("hex")
const a = Buffer.from(computed)
const b = Buffer.from(sig)
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}Python example
import hmac
import hashlib
import time
def verify_webhook_signature(body, signature_header, secret, tolerance=300):
parts = dict(p.split("=") for p in signature_header.split(","))
ts = parts.get("t")
sig = parts.get("v1")
if not ts or not sig:
return False
if time.time() - int(ts) > tolerance:
return False
payload = f"t.{ts}.{body}".encode()
computed = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(computed, sig)Retry Schedule
Failed deliveries are retried up to 5 times with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
After the 5th failure the delivery moves to dead_letter status. You can manually retry dead-letter deliveries via the retry endpoint.
A delivery is considered successful when your endpoint responds with any HTTP 2xx status code. Redirects are not followed (maxRedirects: 0).
Best Practices
- Respond quickly — your endpoint should return a 2xx within 10 seconds. Use a queue for long-running work.
- Verify signatures — always verify the
x-public-url-signatureheader before processing. - Idempotent processing — the
idfield in the body is unique per delivery. Use it to deduplicate. - Handle retries — the same event may be delivered more than once. Design your consumer to be idempotent.
- Monitor delivery logs — check the deliveries endpoint for failures and response times.
- Rotate secrets periodically — use the secret rotation endpoint to rotate secrets without downtime.
- Use
"*"sparingly — subscribing to all events is convenient butclick.receivedis high-volume. Prefer specific event types.
Rate Limits
Webhook delivery is subject to the workspace's plan limits for webhook_endpoints (number of endpoints) and api_requests_monthly (API calls). Delivery retries count against the monthly API request limit.
Error Codes
| Code | Meaning |
|---|---|
PLAN_CAPABILITY_REQUIRED | Webhooks are not included in your plan |
PLAN_LIMIT_REACHED | Webhook endpoint limit reached for your plan |
VALIDATION_ERROR | Invalid request payload |
NOT_FOUND | Endpoint or delivery not found |
FORBIDDEN | Insufficient role or workspace mismatch |