# BaseLinker Webhooks

BaseLinker (rebranded Base.com) is a Polish multichannel e-commerce
platform — order management, warehouse/inventory, and integrations with
marketplaces, stores and couriers.

This is not a normal webhook source. Three things make BaseLinker unlike every
other provider in this repo, and all three must be reflected in your handler:

1. The transport is HTTP `HEAD`, not `POST`. A HEAD request has no body by
  definition — reading `req.body` / `await request.json()` yields nothing or
  throws.
2. The entire payload is in the query string. Read it from the parsed query
  params. Query values are always strings — coerce numerics explicitly.
3. There is no signature verification. None. No HMAC, no signature header, no
  timestamp/replay check, no shared secret, no handshake or challenge step.

BaseLinker also publishes no webhook documentation at all. Its public API
(`api.baselinker.com`, ~195 methods over `connector.php`) is strictly
request/response, with change tracking done by polling (`getJournalList`,
`getOrderReturnJournalList`, `getInventoryProductLogs`). Neither the English nor
the Polish help centre documents an outbound webhook. Everything below about the
wire format is stated as observed, not documented — see
[references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/baselinker-webhooks/references/overview.md) for exactly what was observed and
what was not.

## When to Use This Skill

* How do I receive BaseLinker (Base.com) webhooks?
* Why is my BaseLinker webhook body empty / why does `req.body` have nothing in it?
* How do I handle an HTTP HEAD webhook in Express, Next.js, or FastAPI?
* How do I read `order_id` and `state` from a BaseLinker callback?
* How do I verify a BaseLinker webhook signature? (You cannot — there is none.)
* Is `X-BLToken` a webhook signature? (No — it is the outbound API request header.)
* How do I track BaseLinker order changes reliably? (Poll `getJournalList`.)

## Verification (core): there is none

BaseLinker provides no cryptographic authentication for these callbacks.
There is nothing to verify with, so do not write an HMAC verifier, a signature
header check, a timestamp/replay window, or a shared-secret comparison against
something BaseLinker sends — none of those inputs exist. Inventing one produces
a handler that silently rejects (or silently pretends to check) every delivery.

This is corroborated by Hookdeck's own API spec, where the Baselinker source's
auth schema is empty:

```jsonc
// SourceConfigBaselinkerAuth
{ "properties": {}, "additionalProperties": false }   // accepts no secret at all

```

Every HMAC-based source in that same spec carries a `webhook_secret_key`.
BaseLinker sits in the small cohort of zero-property auth schemas alongside AWS
SNS, Microsoft Graph, Microsoft SharePoint, Monday, Strava, Tikkie, Ethoca and
Zift. There is also no handshake/challenge/ack step: unlike Trello (which uses
HEAD as a verification probe), a BaseLinker HEAD request resolves no challenge
controller and goes straight to ingestion.

What to do instead — defence in depth, none of it provided by the platform:

* Endpoint-URL secrecy. Use a long, unguessable path
  (`/webhooks/baselinker/8f3c…`). Never log the full URL.
* Network controls. TLS only; a WAF/rate limit in front; restrict by source IP
  if you can establish one for your account (BaseLinker publishes no allowlist).
* A token you append to the endpoint URL. Because you control the URL you
  register, you can add your own query param — `?token=<random>` — and compare it
  timing-safely. This is your secret round-tripped back to you, not a
  BaseLinker signature, and it is visible in the URL. The examples implement this
  optional check.

```javascript
const crypto = require('crypto');

// OPTIONAL, and NOT a BaseLinker signature: a token you appended to the endpoint
// URL yourself, echoed back in the query string. BaseLinker signs nothing.
function verifyUrlToken(query, expected) {
  if (!expected) return true; // not configured — nothing to check
  const provided = query.token;
  if (typeof provided !== 'string') return false;
  const a = Buffer.from(provided), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

> For complete handlers with tests, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/baselinker-webhooks/examples/express/),
> [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/baselinker-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/baselinker-webhooks/examples/fastapi/).

## The Payload: Query Params on a Bodyless HEAD

The only query params actually observed (in Hookdeck's Baselinker ingestion
fixtures) are:

| Param | Observed example | Notes |
| --- | --- | --- |
| `order_id` | `42` | A string on the wire — coerce with `Number(...)` / `int(...)` |
| `state` | `packed` | Opaque string. Not a documented enum, and not an event-type discriminator |

These are observed examples, not a documented or exhaustive parameter list.
Do not assume any param is present, do not invent additional param names, and do
not build a `switch` over a fixed set of `state` values as if it were an event
catalogue.

```
HEAD /webhooks/baselinker?order_id=42&state=packed HTTP/1.1
Host: your-app.example.com

```

Because the delivery carries no body, it tells you that something changed, not
what. Fetch the detail from the API with `getOrders` (see below).

## Framework Wiring (the part everyone gets wrong)

| Framework | Correct | Wrong |
| --- | --- | --- |
| Express | `app.head('/webhooks/baselinker', handler)` — read `req.query` | `app.post(...)`, `express.json()` on the route, `req.body` |
| Next.js (App Router) | `export async function HEAD(request: NextRequest)` — read `request.nextUrl.searchParams` | exporting `POST`, `await request.json()` |
| FastAPI | `@app.head('/webhooks/baselinker')` — typed query args or `request.query_params` | `@app.post(...)`, a Pydantic body model |

Express's `app.get()` also answers HEAD requests, but be explicit: register
`app.head()` so the intent is visible and a future `app.get()` refactor cannot
change the behaviour. Do not mount a JSON body parser on this route — there is
no body to parse.

## Responding

A HEAD response MUST NOT carry a body ([RFC 9110 §9.3.2](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.2)).
Reply with a bare `200` and no payload:

```javascript
res.sendStatus(200);                        // Express — Node omits the body for HEAD
return new Response(null, { status: 200 }); // Next.js

```

```python
return Response(status_code=200)  # FastAPI (fastapi.Response)

```

Never `res.json(...)` / `NextResponse.json(...)` / return a dict from FastAPI on
this route.

Because of that rule, when you route BaseLinker through Hookdeck the request id
comes back in the `x-hookdeck-request-id` response header (exposed via
`Access-Control-Expose-Headers`) rather than in a body — use it to correlate a
delivery with its dashboard entry.

## Fetching the Order Detail (`X-BLToken`)

`X-BLToken` is BaseLinker's request auth header for your outbound calls to
its API. It is not a webhook signature and never appears on an inbound
delivery. After acknowledging the HEAD, look the order up:

```bash
curl -X POST https://api.baselinker.com/connector.php \
  -H 'X-BLToken: YOUR_API_TOKEN' \
  -d 'method=getOrders' \
  --data-urlencode 'parameters={"order_id":42}'

```

Rate limit: 100 requests/minute. For complete change tracking (the callback is
undocumented and not guaranteed to cover every transition), poll
`getJournalList` with a `last_log_id` cursor — see
[references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/baselinker-webhooks/references/overview.md).

## Environment Variables

```bash
# Your BaseLinker API token, for fetching order detail after a callback.
# Sent as the X-BLToken REQUEST header — it is NOT a webhook signature.
BASELINKER_API_TOKEN=your_api_token

# OPTIONAL. A random token YOU append to the endpoint URL you register
# (?token=...). BaseLinker provides no secret; this is your own shared token.
BASELINKER_URL_TOKEN=

```

## Local Development

```bash
npx hookdeck-cli listen 3000 baselinker --path /webhooks/baselinker

```

No account required — the CLI creates a guest account on first run and gives you a
public HTTPS URL plus a web UI for inspecting requests. When you create a
Baselinker Source in Hookdeck, its `allowed_http_methods` is seeded to
`["HEAD"]`. That seeding is an unmanaged default: it sets the initial
selection only, stays editable, and is not re-applied on later updates.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/baselinker-webhooks/references/overview.md) - What is (and isn't) known about the callback, observed query params, the Automatic Actions background, polling alternatives
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/baselinker-webhooks/references/setup.md) - Preparing the receiver, why the registration step cannot be fully specified, securing an unauthenticated endpoint, Hookdeck source configuration
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/baselinker-webhooks/references/verification.md) - Why there is nothing to verify, and what to do instead