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.
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.
await native.setBucketNotification("uploads", {
webhookUrl: "https://my-app.example.com/hooks/lockwell",
events: ["object-created", "object-removed"],
filters: { prefix: "incoming/", suffix: ".pdf" },
});
// Read it back. The view carries `hasSecret`, never the secret itself:
const { configs } = await native.getBucketNotification("uploads");
// Clear it:
await native.deleteBucketNotification("uploads");_, err := native.SetBucketNotification(ctx, "uploads", lockwellnative.SetBucketNotificationInput{
Configs: []lockwellnative.NotificationConfig{{
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"},
},
}},
})
// Read it back (HasSecret only, never the secret):
views, _ := native.GetBucketNotification(ctx, "uploads")
// Clear it:
_ = native.DeleteBucketNotification(ctx, "uploads")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");
native.setBucketNotification("uploads", config);
// Read it back (each config reports hasSecret() only):
var current = native.getBucketNotification("uploads");
// Clear it:
native.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-Signatureis the lowercase-hexHMAC-SHA256of the exact request body bytes.X-Lockwell-Eventis 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.
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. :::
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 });
});import "github.com/lockwell/lockwell/pkg/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...
}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.
About the signing secret
Lockwell never hands you the per-config signing secret. It is generated and held server-side (crypto/rand, sealed at rest) and is never returned on the wire. getBucketNotification / setBucketNotification report only hasSecret, a flag that a server-side secret exists, never the secret itself.
So verifyWebhook is for the case where your app already holds the secret out of band. The supported pattern is to bake a secret your app controls into the webhook URL you register (a path or query token), and verify against that. For example, register https://my-app.example.com/hooks/lockwell?t=<your-secret> and verify deliveries with <your-secret>. Do not expect Lockwell to hand you the per-config signing secret to feed into verifyWebhook. It will not.
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.
verifyWebhookis also a method onLockwellKit. - Edge runtimes. Receive and verify webhooks on a Worker.
- Data operations. Act on the object the event points to.