# Inbound Webhooks (https://eulabel.eu/docs/webhooks/inbound-webhooks)

> Fetch the complete documentation index at: https://eulabel.eu/docs/llms.txt
> Use this file to discover all available pages before exploring further.
> Full content: https://eulabel.eu/docs/llms-full.txt
> Append .md to any page URL for markdown, or send Accept: text/markdown.



When product data changes in your PIM (e.g., DatoCMS, Salsify, Akeneo), a webhook triggers EUlabel to regenerate the passport automatically.

At a glance [#at-a-glance]

* Inbound endpoint: `POST /v1/webhooks/ingest`
* Always verify HMAC signatures using the **raw request body**
* Keep your handler idempotent (retries can deliver duplicates)

Flow [#flow]

A product record is created or updated in your PIM.
The PIM sends a webhook POST to `https://api.eulabel.eu/v1/webhooks/ingest`.
EUlabel validates the webhook signature.
The platform maps PIM fields to passport fields.
The passport is regenerated (or created if new).
The QR code URL continues to resolve to the updated passport.
Recommended handler layout [#recommended-handler-layout]

- src/

- webhooks/
  - eulabel.ts
- app/

- api/

- webhooks/

- eulabel/
  - route.ts
Example payload (DatoCMS) [#example-payload-datocms]
```json
{
  "event_type": "item.update",
  "entity_type": "item",
  "entity": {
    "id": "Kq7rxxPPS32ErMA8nxxMTA",
    "type": "product",
    "attributes": {
      "product_name": "Quinta dos Carvalhais Alfrocheiro 2019",
      "barcode": [
        {
          "barcode_number": "5601012012200",
          "capacity_value": "750 mL",
          "vintage_year": 2019
        }
      ],
      "list_of_ingredient": [
        { "ingredient_name": "Grapes", "quantity": "99.986%" },
        { "ingredient_name": "Sulphites", "quantity": "0.014%" }
      ],
      "contains_sulfites": true,
      "energy_value_kj": "351 kJ/100mL",
      "alcohol_content_by_volume": "13.5%vol.",
      "product_country": "PT",
      "product_region": "Dao"
    }
  }
}
```
Signature verification [#signature-verification]

Every inbound webhook includes a signature header:
```text
X-Webhook-Signature: sha256=<HMAC-SHA256 of request body using shared secret>
```

> **Error**
> Always use a constant-time comparison when verifying HMAC signatures. A naive string comparison is vulnerable to timing attacks.

Verify it by computing the HMAC of the raw request body with your shared secret and comparing to the provided signature. Requests with invalid signatures are rejected with HTTP 401.

> **Warning**
> Many frameworks parse JSON and change whitespace or key ordering. Signature verification must be computed over the **exact raw body bytes** received by your server.

Verification examples [#verification-examples]

### TypeScript (SDK)

```typescript
import { EUlabel } from "@eulabel/sdk";

const client = new EUlabel({ apiKey: "sk_test_..." });

const isValid = client.webhooks.verify(
  requestBody,
  request.headers["x-webhook-signature"],
  process.env.WEBHOOK_SECRET,
);

if (!isValid) {
  return new Response("Invalid signature", { status: 401 });
}
```
### Node.js (raw)

```typescript
import crypto from "node:crypto";

function verifyWebhookSignature(
  rawBody: string | Buffer,
  signatureHeader: string,
  secret: string,
): boolean {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader),
  );
}

// Next.js App Router example
export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get("x-webhook-signature");

  if (!signature || !verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  // Process the webhook payload...

  return new Response("OK", { status: 200 });
}
```
### Python

```python
import hmac
import hashlib
import json

def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

# Flask example
@app.route("/webhooks/eulabel", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data()
    signature = request.headers.get("X-Webhook-Signature", "")

    if not verify_webhook_signature(raw_body, signature, WEBHOOK_SECRET):
        return "Invalid signature", 401

    payload = json.loads(raw_body)
    # Process the webhook payload...

    return "OK", 200
```
### CURL

```bash
# Compute HMAC-SHA256 of the raw body
echo -n "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"
```
Field mapping [#field-mapping]

EUlabel maps PIM-specific fields to the passport schema. Mapping configurations are stored per tenant, allowing each organization to define their own field correspondence.

| PIM Field                                 | EUlabel Passport Field |
| ----------------------------------------- | ---------------------- |
| `product_name`                            | `name`                 |
| `barcode[].barcode_number`                | `gtin`                 |
| `list_of_ingredient[].ingredient_name`    | `ingredients[]`        |
| `contains_sulfites`, `contains_egg`, etc. | `allergens`            |
| `energy_value_kj`, `fat`, `sugars`, etc.  | `nutrition`            |
| `product_country` + `product_region`      | `origin`               |
| `producer[].name`                         | `supplier`             |

Supported platforms [#supported-platforms]

| Platform   | Integration                     | Status      |
| ---------- | ------------------------------- | ----------- |
| DatoCMS    | Native webhooks + GraphQL       | Available   |
| Salsify    | Webhooks + REST API             | Coming soon |
| Akeneo     | Event API + REST                | Coming soon |
| Contentful | Webhooks + Content Delivery API | Coming soon |
| Custom PIM | Generic webhook endpoint        | Available   |

For platforms without native webhook support, call the EUlabel API directly using the [SDK](https://eulabel.eu/docs/documentation/sdks).

