> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ycloud.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Implement a webhook receiver

> Create an endpoint, verify signatures, durably accept events, and handle webhook retries and suspension.

Receive YCloud events at a public endpoint and process them without losing or
repeating business actions. Use HTTPS for production, preserve the raw request
body, and verify each signature before accepting the event.

## Register your endpoint

In the [YCloud Console](https://www.ycloud.com/console), open **Developers >
Webhook**, select **Add Endpoints**, enter the **Endpoints URL**, select
**Events**, and save with **Confirm**. You can also use
`POST /v2/webhookEndpoints`; see [Configure webhooks](/en/api-reference/guides/api-fundamentals/configure-webhooks)
for the request and response.

* You can configure up to 20 endpoints per account.
* The URL must be publicly reachable and must not resolve to a private address.
* The URL supports up to 500 characters; the optional description supports up to 400.
* Save the returned signing `secret` securely.

## Read the event request

```http theme={"theme":{"light":"github-light","dark":"github-dark"}}
Content-Type: application/json
YCloud-Signature: t=1762224357,s=SIGNATURE_HEX
X-Webhook-Endpoint-ID: WEBHOOK_ENDPOINT_ID
```

| Field | Meaning |
| - | - |
| `id` | Event ID. Use it to deduplicate delivery. |
| `type` | Event type, such as `whatsapp.message.updated`. |
| `apiVersion` | API version used by the event. |
| `createTime` | Event creation timestamp. |
| Event-specific object | Payload such as `whatsappMessage`, `whatsappInboundMessage`, or `contactCreated`. |

For complete examples, see [Webhook payloads](/en/api-reference/guides/examples/webhook-examples/webhook-payload-examples).

## Verify the signature

The `YCloud-Signature` header has the form `t=TIMESTAMP,s=SIGNATURE`.
The timestamp is Unix time in seconds.

1. Extract `t` and `s` from the header.
2. Join the timestamp, a period, and the exact raw request body bytes.
3. Compute HMAC-SHA256 with the endpoint's signing secret.
4. Compare the hexadecimal result using a constant-time comparison.

Do not serialize parsed JSON to reconstruct the body. Whitespace, key order,
or Unicode escaping changes the signature input.

The example below also uses a configurable five-minute timestamp tolerance to
reduce replay risk. This tolerance is an application policy, not a YCloud
retry deadline. Keep your server clock synchronized.

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import crypto from "node:crypto";

export function verifySignature(rawBody, header, secret, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string" || !secret) return false;
  const fields = header.split(",").map(part => part.trim().split("="));
  const timestamps = fields.filter(([key]) => key === "t").map(([, value]) => value);
  const signatures = fields.filter(([key]) => key === "s").map(([, value]) => value);
  if (timestamps.length !== 1 || !/^\d+$/.test(timestamps[0])) return false;
  const timestamp = timestamps[0];
  if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();
  return signatures.some(signature =>
    typeof signature === "string" && /^[a-fA-F0-9]{64}$/.test(signature) &&
    crypto.timingSafeEqual(expected, Buffer.from(signature, "hex"))
  );
}
```

## Accept before acknowledging

Persist the validated event to a durable queue or transactional inbox before
returning `2xx`. If storage is unavailable, return a failure so delivery can be
retried. After durable acceptance, let your worker handle business-processing
failures with its own retries.

This Express handler uses an application-provided `persistEvent` operation.
Implement it as an atomic insert keyed by event `id`; an already stored event
must count as success. Do not mark an event as processed before its business
transaction commits.

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import express from "express";
import { verifySignature } from "./signature.js";
import { persistEvent } from "./event-inbox.js";

const app = express();
const secret = process.env.YCLOUD_WEBHOOK_SECRET;
if (!secret) throw new Error("YCLOUD_WEBHOOK_SECRET is required");

// Register the raw-body route before any JSON-parsing middleware.
app.post("/webhook", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("YCloud-Signature"), secret)) {
    return res.sendStatus(401);
  }
  let event;
  try {
    event = JSON.parse(req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }
  if (!event || typeof event.id !== "string" || typeof event.type !== "string") {
    return res.sendStatus(400);
  }
  try {
    await persistEvent(event.id, event);
    return res.sendStatus(204);
  } catch {
    return res.sendStatus(503);
  }
});

app.listen(3000);
```

### Java and Spring example

This Java 17 example applies the same verification and durable-acceptance order.
Provide an `EventInbox` bean backed by a transactional store with a unique
constraint on event ID. `insertIfAbsent` must commit the full event before it
returns; duplicate IDs return successfully. Your worker can then process and
mark stored events in its own transaction.

```java theme={"theme":{"light":"github-light","dark":"github-dark"}}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;

@RestController
public class WebhookController {
    private final String secret;
    private final ObjectMapper mapper;
    private final EventInbox inbox;

    public WebhookController(@Value("${YCLOUD_WEBHOOK_SECRET}") String secret,
                             ObjectMapper mapper, EventInbox inbox) {
        if (secret.isBlank()) throw new IllegalArgumentException("Missing webhook secret");
        this.secret = secret;
        this.mapper = mapper;
        this.inbox = inbox;
    }

    @PostMapping("/webhook")
    public ResponseEntity<Void> receive(
            @RequestHeader(value = "YCloud-Signature", required = false) String header,
            @RequestBody byte[] body) {
        if (!verify(body, header)) return ResponseEntity.status(401).build();
        JsonNode event;
        try {
            event = mapper.readTree(body);
            if (event == null || !event.path("id").isTextual() || !event.path("type").isTextual()) {
                return ResponseEntity.badRequest().build();
            }
        } catch (Exception invalidJson) {
            return ResponseEntity.badRequest().build();
        }
        try {
            inbox.insertIfAbsent(event.path("id").asText(), body);
            return ResponseEntity.noContent().build();
        } catch (Exception storageFailure) {
            return ResponseEntity.status(503).build();
        }
    }

    private boolean verify(byte[] body, String header) {
        if (header == null) return false;
        String timestamp = null;
        java.util.List<String> signatures = new java.util.ArrayList<>();
        for (String part : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length != 2) continue;
            if (pair[0].equals("t")) {
                if (timestamp != null) return false;
                timestamp = pair[1];
            }
            if (pair[0].equals("s")) signatures.add(pair[1]);
        }
        if (timestamp == null || !timestamp.matches("[0-9]{1,12}")) return false;
        try {
            long age = Instant.now().getEpochSecond() - Long.parseLong(timestamp);
            if (Math.abs(age) > 300) return false;
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
            byte[] expected = mac.doFinal(body);
            for (String signature : signatures) {
                if (signature.matches("[a-fA-F0-9]{64}") &&
                    MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature))) return true;
            }
            return false;
        } catch (Exception invalidSignature) {
            return false;
        }
    }

    public interface EventInbox {
        void insertIfAbsent(String eventId, byte[] eventBody) throws Exception;
    }
}
```

Do not use a separate “already processed” Redis write before queueing the event:
if the enqueue fails after that write, a retry could be dropped. Use an atomic,
durable inbox or a queue whose acceptance and duplicate handling are atomic.

## Timing, retries, and suspension

Return a `2xx` response promptly; aim for less than 6 seconds. Slow responses
above 10 seconds can reduce delivery priority. Do not run slow business work
inside the HTTP handler.

For a non-`2xx` response or missing response, the default retry intervals are:

| Retry | Delay |
| - | - |
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
| 7 | 2 hours |

YCloud stops retrying that event after the configured retry limit. With the
default settings, a URL can be suspended
for 3 minutes when it reaches 200 failures per minute or 10 minutes of summed
failure time within a minute across concurrent requests. Requests are paused
during suspension and resume afterward.

Also monitor the endpoint's `status`. A `pending` endpoint does not receive
events; see [endpoint configuration](/en/api-reference/guides/api-fundamentals/configure-webhooks).

## Verify your receiver

* A valid signature and durably stored event return `2xx`.
* Modified bodies, malformed signatures, and stale timestamps are rejected.
* A duplicate event is accepted without repeating its business action.
* A storage outage returns a failure and allows redelivery.
* Unknown event types do not crash the receiver.
* Processing failures are retried by your worker after acceptance.
* Secrets and complete customer payloads are not written to application logs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.