# Webhook endpoints

Register HTTPS URLs to receive completion events.

## Endpoints

| Method | Path | Scope | Credits | Description |
|---|---|---|---|---|
| POST | `/v1/webhook-endpoints` | `webhooks:write` | 0 | Create |
| GET | `/v1/webhook-endpoints` | `webhooks:read` | 0 | List |
| GET | `/v1/webhook-endpoints/{id}` | `webhooks:read` | 0 | Retrieve, incl. recent delivery status |
| DELETE | `/v1/webhook-endpoints/{id}` | `webhooks:write` | 0 | Delete |

## Create

| Parameter | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes | HTTPS only; must respond `2xx` within 10 seconds |
| `events` | string[] | yes | Event types to receive, or `["*"]` |
| `description` | string | no | |

```json
{
  "object": "webhook_endpoint",
  "id": "whk_0plq8vn2ta",
  "url": "https://example.com/hooks/scispace",
  "events": [
    "search.completed",
    "search.failed"
  ],
  "secret": "whsec_9f2b7a41c0d85e3f",
  "status": "enabled",
  "created_at": "2026-08-10T11:55:02Z"
}
```

> [!warning] The secret is returned once
> `whsec_…` appears only in the create response. Store it in your secret manager — you need it for
> every verification.

## Verifying signatures

Each delivery carries:

```
SciSpace-Signature: t=1754827200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

Compute `HMAC-SHA256(secret, "{t}.{raw_request_body}")` and compare in constant time. Reject if the
timestamp is more than 5 minutes old — that check is what stops replay.

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

```typescript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: Buffer, header: string, secret: string, tolerance = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2) as [string, string]));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
  const expected = createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(`${parts.t}.`), rawBody]))
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

> [!danger] Verify against the raw body
> Signing covers the exact bytes we sent. If your framework parses JSON before you verify, capture the
> raw body first or every signature will fail.

## Handling deliveries

1. Verify the signature
2. Return `2xx` immediately — do the work asynchronously
3. Deduplicate by `event.id`
4. Treat the resource's `status` as truth; ignore arrival order

## Retries and disabling

5 attempts at 1 m, 5 m, 30 m, 2 h, 12 h. Recent failures are visible on `GET
/v1/webhook-endpoints/{id}`. An endpoint that fails every delivery for 7 consecutive days is
disabled and the org owner is emailed.

## Errors

| Status | `code` | When |
|---|---|---|
| 400 | `url_insecure` | non-HTTPS URL |
| 400 | `parameter_invalid` | unknown event type |
| 409 | `endpoint_exists` | that URL is already registered |

## Related

webhook · jobs · streaming-deep-review
