# Webhooks

Lockwell can POST a signed event notification to your endpoint whenever an object is created or removed in a bucket. You
configure the target with the native client, and you verify each delivery with a constant-time HMAC in one call.

**Webhook is the only delivery target.** SNS, SQS, and Lambda destinations are a deliberate non-goal (the server returns
`501`). There is one HTTP(S) target per configuration.

::: warning Keep credentials out of the target URL Query strings are accepted, but a failed delivery currently records
the full target URL in the job's `LastError` and append-only audit reason, and operator job output can print it. Use a
credential-free HTTPS URL with no tokens, API keys, or secrets in its query string. Authenticate deliveries by verifying
Lockwell's HMAC signature headers with the generated signing secret. :::

Notifications are a native-only surface. The S3 `PutBucketNotificationConfiguration` path enforces the same webhook-only
rule, and there is no admin-UI control for it.

## Configure a notification

`setBucketNotification` wires a webhook for the bucket's `s3:ObjectCreated:*` and `s3:ObjectRemoved:*` events, with
optional `prefix` / `suffix` key filters. The Node helper accepts the friendly shorthand `object-created` /
`object-removed` (or the canonical `s3:Object*` names) and the `{ prefix, suffix }` filter object.

::: code-group

```ts [Node]
const created = await native.setBucketNotification("uploads", {
  id: "uploads-events",
  webhookUrl: "https://my-app.example.com/hooks/lockwell",
  events: ["object-created", "object-removed"],
  filters: { prefix: "incoming/", suffix: ".pdf" },
});
const signingSecret = created.configs[0].signingSecret; // shown once; store securely

// Read it back. The view carries `hasSecret`; signingSecret is omitted:
const { configs } = await native.getBucketNotification("uploads");

// Clear it:
await native.deleteBucketNotification("uploads");
```

```go [Go]
created, err := native.SetBucketNotification(ctx, "uploads", lockwellnative.SetBucketNotificationInput{
    Configs: []lockwellnative.NotificationConfig{{
        ID:         "uploads-events",
        WebhookURL: "https://my-app.example.com/hooks/lockwell",
        Events:     []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*"},
        Filters: []lockwellnative.NotificationFilter{
            {Name: "prefix", Value: "incoming/"},
            {Name: "suffix", Value: ".pdf"},
        },
    }},
})
signingSecret := created[0].SigningSecret // shown once; store securely

// Read it back (HasSecret only; SigningSecret is empty):
views, _ := native.GetBucketNotification(ctx, "uploads")

// Clear it:
_ = native.DeleteBucketNotification(ctx, "uploads")
```

```java [Java]
import com.lockwell.sdk.nativeapi.NativeTypes.NotificationConfig;

var config = new NotificationConfig()
    .webhookUrl("https://my-app.example.com/hooks/lockwell")
    .event("s3:ObjectCreated:*")
    .event("s3:ObjectRemoved:*")
    .filter("prefix", "incoming/")
    .filter("suffix", ".pdf");

config.id("uploads-events");
var created = nativeClient.setBucketNotification("uploads", config);
String signingSecret = created.configs().get(0).signingSecret(); // shown once

// Read it back (each config reports hasSecret(); signingSecret() is null):
var current = nativeClient.getBucketNotification("uploads");

// Clear it:
nativeClient.deleteBucketNotification("uploads");
```

:::

Passing an **empty config list** clears the bucket's notification configuration (S3 "clear" semantics). Configuring a
notification needs the `admin` verb on the bucket, so a read-only or data-only key is denied with a 403.

## How a delivery is signed

Each delivery carries two headers:

- **`X-Lockwell-Signature`** is the lowercase-hex `HMAC-SHA256` of the exact request body bytes.
- **`X-Lockwell-Event`** is the event type (for quick routing; not part of the signature).

The payload body is S3-event-shaped: `{ "Records": [ { "eventName": ..., "s3": { ... } } ] }`.

## Verify an incoming delivery

Verify against the raw request bytes with `verifyWebhook`, which reproduces the server's signing exactly and compares in
**constant time**.

::: warning Verify the exact bytes Sign the raw body you received. Do not parse and re-serialize the JSON first.
Re-encoding changes the bytes and breaks the signature, so verification fails even on a genuine delivery. Read the raw
body, verify, then parse. :::

::: code-group

```ts [Node]
import { verifyWebhook, WEBHOOK_SIGNATURE_HEADER_NAME } from "@kelphect/sdk";

app.post("/hooks/lockwell", async (req, res) => {
  const raw = await readRawBody(req); // exact bytes; do NOT JSON.parse then re-stringify
  const sig = req.headers[WEBHOOK_SIGNATURE_HEADER_NAME.toLowerCase()];

  const ok = await verifyWebhook(raw, sig, process.env.WEBHOOK_SECRET);
  if (!ok) return res.status(401).end();

  const event = JSON.parse(raw.toString("utf8"));
  for (const record of event.Records ?? []) {
    /* ...do real work... */
  }
  res.json({ verified: true });
});
```

```go [Go]
import "github.com/RusticStack/lockwell-sdk-go/lockwellkit"

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // exact bytes

    sig := r.Header.Get(lockwellkit.WebhookSignatureHeader)
    if !lockwellkit.VerifyWebhook(raw, sig, secret) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    event := r.Header.Get(lockwellkit.WebhookEventHeader)
    log.Printf("verified delivery: %s", event)
    // ...do real work with the verified body...
}
```

```java [Java]
import com.lockwell.sdk.kit.LockwellKit;

byte[] raw = request.getInputStream().readAllBytes(); // exact bytes
String sig = request.getHeader("X-Lockwell-Signature");

if (!LockwellKit.verifyWebhook(raw, sig, secret)) {
    response.setStatus(401);
    return;
}
// ...do real work with the verified body...
```

:::

`verifyWebhook` is exported standalone (no kit instance needed) and is **edge-safe** in Node. It uses WebCrypto HMAC
plus a constant-time compare with no `node:crypto`, so it runs unchanged on Cloudflare Workers, Vercel Edge, Bun, and
Deno. See [edge runtimes](/guide/edge-runtimes).

## About the signing secret

Lockwell generates a 256-bit per-config secret server-side, returns it exactly once when a new config ID is created, and
stores only encrypted material thereafter. Capture `signingSecret` from the creation response and put it in the
receiver's secret manager. GET and same-ID updates expose only `hasSecret`; losing the value requires rotation through a
new config ID. S3 PUT exposes the equivalent one-time map in `X-Lockwell-Webhook-Secrets`.

If your receiver has no secret to verify against, **fail closed** (reject the delivery) rather than trust an unverified
body.

## Next steps

- [The app kit](/guide/app-kit). `verifyWebhook` is also a method on `LockwellKit`.
- [Edge runtimes](/guide/edge-runtimes). Receive and verify webhooks on a Worker.
- [Data operations](/guide/data-operations). Act on the object the event points to.
