Events
The per-store event log, topic patterns and predicates, subscriptions, CloudEvents deliveries signed per Standard Webhooks, retries and the echo sink.
Every write to your store is recorded as an event, by construction: an order paid, a product's inventory moved, a page published, a return approved. You and your agent subscribe to any of them with topic wildcards and a predicate, and Formahand delivers them to a URL you own — signed, retried and logged — without Formahand having to curate a list of "supported webhook events" first. Automations run on the same log, so the same event can also drive an email, a note or a tag without any receiver at all.
1. The model
One event per write, kept in your own store:
id a ULID — time-ordered, so sorting by id sorts by time and an id works as a cursor
type <resource>.<verb>, or a semantic alias (order.paid)
resource order, product, …
resourceId
occurredAt
actor who wrote (§3)
data the object after the write; for a deletion, the last known object
changed the top-level field names that changed ([] for created and deleted)
previous the previous values of the changed fields only
- Verbs.
created(no object before),updated(both),deleted(no object after).storefront.publisheduses an explicit verb of its own. A write whose before and after views are equal records nothing, so a repeated idempotent call (a redelivered payment notification, a second "mark paid") is silent. - Diff. Top-level fields are compared structurally (nested objects and arrays compared by value, key order ignored).
nulland a missing field are equal.changedis sorted;previous[field]holds the value before the write (nullwhen the field did not exist). - Bounding.
dataandpreviousare kept under 32 KB: arrays are cut to 25 entries and strings to 1000 characters, then to 5 and 200, and as a last resort only scalar fields survive with_truncated: true. Image arrays are URLs already and are never expanded. - Retention. Events are kept 90 days, deliveries that are no longer pending 30 days (deleting an event takes its deliveries with it), and the echo sink keeps its last 20 bodies.
- Recording never fails the write. If an event cannot be recorded, the write still succeeds and the failure is logged without your data.
2. Vocabulary
The resources and the writes that emit them.
| Resource | Emitted by | Data |
|---|---|---|
order | checkout reserve (order.created, actor storefront:checkout); payment events (paid, failed, refunded, disputed); fulfil; cancel; refund-recorded; note; confirmation-sent; tracking record (a refresh, a scheduled re-check, a carrier notification); set-tracking; label purchase, void and refund | the order detail as GET /api/orders?orderId=<id> renders it: totals, customer, address, items, carrier/tracking fields, trackingEvents, shippingLabel, timeline, trackingPageUrl |
product | create, update, delete, bulk archive/activate/delete; inventory changes at checkout reserve, settlement and cancellation (one product.updated per product whose view changed) | the product as GET /api/products lists it, with variants |
collection | upsert, delete | the collection with productIds and productCount |
customer | first paid order (customer.created, then roll-ups as customer.updated); CRM upsert; newsletter sign-up (actor storefront:newsletter) | the customer as GET /api/crm/contacts lists it |
discount | create (discount.created), archive (discount.updated, changed: ["status"]); one discount.created per code made from a template (checkout-rules) | the discount; a made code carries code, role: "child", template, parentId, expiresAt, boundEmail and mintedBy |
gift_exception | set and removed (checkout-rules "Gifts below cost") | { id, subject, productId, createdBy } |
page | create, update, delete | slug, title, bodyMarkdown, status |
navigation | replace (navigation.updated, resource id navigation) | { header, footer } |
seo | first write seo.created, then seo.updated (resource id seo) | title, metaDescription, socialTitle, socialDescription, noindex |
shipping | replace (shipping.updated, resource id shipping) | allowedCountries, rates, origin, parcel |
shipping_label | purchase (shipping_label.created), void/refund transitions (shipping_label.updated); the order is updated alongside | the label record. The carrier-platform ids are labelTransactionId and labelRefundId; the older shippoTransactionId / shippoRefundId keys carry the same values and are deprecated — they are sent for one more release and then removed, so read the neutral names |
storefront | publish only: storefront.published (resource id storefront). Draft saves and editor commands are deliberately not events | { liveVersion, publishedRevision, revision, presetId, heading } |
integration | upsert, delete; activating a module also emits integration.updated for the module it deactivates | { id, category, status, config, secretKeys, updatedAt }: never a stored secret, only the names of the secret keys that are set |
payment_settings | replace (resource id payment_settings) | { provider, instructions }; no processor credentials |
payments | what your payment provider says about your account rather than about one order: payments.account_changed, payments.dispute, payments.fraud_warning, payments.payout_failed, payments.tax_notice (payments). Recorded once per notice however many times the provider sends it; the resource id is the provider's own reference for it | { topic, title, detail, severity, amountMinor, currency, dueAt, link }; severity: "action" means you have to do something, and dueAt is a real deadline |
subscription | create, update, delete, and auto-disable | the subscription without its secret or bearer |
flow | create, update, delete, auto-disable | the flow view with secretSet / bearerSet instead of secrets |
email_template | create, update, delete, reset | the template view |
automation_settings | the flows kill switch (resource id flows) | { flowsEnabled } |
cart | cart.abandoned, once per unpaid card or PayPal order an hour after it was started (automations) | { orderId, orderNumber, customerEmail, customerName, currency, subtotalMinor, totalMinor, items, checkoutUrl, unsubscribeUrl, createdAt, abandonedAt, paymentProvider }; the resource id is the order id |
return | a buyer's request (return.requested, actor storefront:returns), then the merchant's moves: return.approved, return.declined, return.received, return.refunded, and return.updated for a close or a restock (orders) | the return view: { id, orderId, orderNumber, label, status, reason, reasonLabel, shopperNote, merchantNote, requestedAt, resolvedAt, refundMinor, restock, returnLabel, currency, customerEmail, customerName, lines, linesMinor, refundableMinor, orderUrl } |
return_settings | replace (resource id return_settings) | { windowDays, policy, instructions } |
test | test.ping from "Send test" / test_subscription | { message, subscriptionId, subscriptionName, sentAt } |
Semantic aliases are additional events over the log, derived from the order diff and written with the same data, changed and previous as the order.updated event (same occurredAt, later id). A subscription on * or order.* therefore receives both order.updated and order.paid for one payment; subscribe to order.updated alone (with a filter) to see each write once. They exist so existing integrations keep working and so common intents have a name:
| Alias | Emitted when |
|---|---|
order.paid | status moved to paid (once per order: transition-based, so a redelivered payment notification fires nothing) |
order.fulfilled | fulfillmentStatus moved to fulfilled (fulfil, set-tracking on an unfulfilled order, label purchase) |
order.cancelled | fulfillmentStatus moved to cancelled |
order.refunded | amountRefundedMinor increased, or status moved to refunded (per non-duplicate refund) |
order.tracking_updated | trackingStatus, trackingEta or trackingEvents changed. A re-check that only moves trackingCheckedAt is an order.updated without this alias |
order.delivered | trackingStatus moved to delivered |
storefront.published | a draft was published (an explicit verb, not derived) |
cart.abandoned | a checkout started but not paid within an hour (explicit verb, once per order) |
return.requested | a buyer asked to send part or all of an order back (explicit verb) |
return.approved | the merchant approved a return and told the buyer how to send it back (explicit verb) |
return.declined | the merchant refused a return, with the reason (explicit verb) |
return.received | the goods came back and were recorded line by line (explicit verb) |
return.refunded | money went back to the buyer for a return (explicit verb) |
test.ping | a synthetic test event |
The legacy product.updated webhook event covered creates too; migrated subscriptions and the /api/webhooks shim map it to the topics product.created + product.updated.
GET /api/events/vocabulary (MCP describe_events) returns the topics actually seen in your store with counts and last occurrence, this resource table, the verbs, the aliases, the topic pattern rules, the filter grammar with examples, the envelope description and an automations pointer to describe_flows, so an agent can author a subscription or a flow without reading this page.
3. Actors
Every event names who wrote:
| Actor | Who that is |
|---|---|
<user id> | a dashboard session |
token:<id> | an API token or MCP tool |
system:stripe-webhook, system:paypal-webhook | a payment provider's notification |
system:shippo-webhook | your carrier platform's tracking and label notifications |
system:cron | a write Formahand made on a schedule |
system:deliveries | the delivery engine auto-disabling a subscription or a flow |
system:flow:<id>:<depth> | a flow's actions (note, tags, cancellation); depth counts flow-caused hops (automations) |
system:flows | Formahand running due flow runs right after a write |
storefront:checkout, storefront:newsletter, storefront:returns | shopper-driven writes on the storefront |
function:<id> | a store function's actions after an order event (tags, notes, a code it made) |
app:<name> | an installed app's code-making calls (its other calls are its token, token:<id>) |
platform | the fallback when a call names nobody |
Every write carries the actor of whoever made it: your session, your token, the shopper, or the schedule.
4. Subscriptions
A subscription is a standing request: these topics, optionally this filter, delivered to this target.
- Limits. 20 subscriptions per store (409), 1–20 topics, name ≤ 80, filter ≤ 1000 characters.
- Topics.
<resource>.<verb>where*matches one whole segment and the pattern needs as many segments as the type:order.updated,order.*,*.created,*. Aliases match like any type (order.delivered). Pattern syntax^[a-z0-9_*]+(\.[a-z0-9_*]+)?$. - Filter. An optional predicate (§5), validated at create and update; a syntax error answers 400
{ error, field: "filter", position }. - Targets.
{ kind: "webhook", url }gets a 32-byte hex secret (64 characters) generated once and returned in the API view.{ kind: "agent", url, bearer? }posts a CloudEvents batch and, when a bearer is set,Authorization: Bearer …; the view shows onlybearerSet. Both kinds are signed the same way. URLs must be public HTTPS with no credentials and no localhost,.local/.internal/.arpa, IP-literal, private-range or bare hosts (§12). Your store's own echo sink (§8) is a valid target. - Enabled / paused.
enabled: falsereceives nothing. After 20 consecutive failed attempts (across deliveries; a success resets the count) the subscription is switched off with adisabledReason(Disabled after 20 consecutive failed deliveries (last: 503).) andsubscription.updatedis recorded. Updating withenabled: trueclears the reason and the counter.
5. Predicates
A small expression language, evaluated the same way in your store and in the dashboard form (no eval, own-property lookups only), so a filter that validates in the editor behaves identically on a real event.
expression := or
or := and ("or" and)*
and := not ("and" not)*
not := "not" not | comparison
comparison := primary (compareOp primary)? -- no chaining: a == b == c is an error
compareOp := "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "contains" | "startsWith" | "endsWith"
primary := literal | path | list | "(" expression ")"
list := "[" (primary ("," primary)*)? "]"
literal := string | number | "true" | "false" | "null"
string := "..." or '...' with escapes \" \' \\ \n \t \r
number := integer or decimal, optionally negative (-3, 2.5)
path := root ("." segment | "[" integer "]")*
root := type | actor | resource | changed | data | previous
Fields: type ("order.paid"), actor, resource, changed (array of field names), data.<path> (the object after the write; data.items[0].sku and data.items.0.sku are the same), previous.<path> (previous values of changed fields). Unknown roots are a parse error.
Semantics: keywords are case-sensitive and there is no =, &&, || or ! (each gets a hint: Unknown operator "="; use == instead of =). Missing paths are undefined and never throw; data.x == null is true when x is missing or null. ==/!= compare structurally (JSON-style deep equality). < <= > >= are true only when both sides are numbers or both are strings (lexicographic). x in list needs an array on the right (a literal or a path such as data.tags). a contains b is array membership or substring; startsWith/endsWith need two strings. not, and, or and the result use JavaScript truthiness (false, null, undefined, 0, NaN, "" are false; empty arrays and objects are true); and/or short-circuit. The segments __proto__, constructor and prototype always resolve to undefined; arrays accept only integer segments. Limits: 1000 characters, 400 tokens, 32 levels of nesting. An empty filter matches everything. Errors carry a 0-based position: Unexpected token ")" at position 8., Unterminated string, Chained comparisons are not allowed, Missing closing parenthesis, Unknown field \foo\; use type, actor, resource, changed, data.<path> or previous.<path>.
Examples (all returned by describe_events):
| Filter | Meaning |
|---|---|
type == "order.paid" | a paid order |
changed contains "trackingStatus" and data.trackingStatus == "delivered" | parcel delivered |
resource == "product" and data.inventory <= 3 and changed contains "inventory" | low stock |
type == "customer.created" | new customer |
type == "product.updated" and previous.status == "archived" and data.status == "active" | product published again |
type == "order.paid" and data.totalMinor >= 10000 | a big order |
data.shippingAddress.country in ["US", "CA"] and not (actor startsWith "system:") | human- or agent-made, North America |
type == "order.refunded" or type == "order.cancelled" | money went back |
"Parcel delivered → send the buyer an email" as one subscription, pointing at the agent or function that sends it:
POST /api/subscriptions
{
"name": "Delivered → thank-you email",
"topics": ["order.updated"],
"filter": "changed contains \"trackingStatus\" and data.trackingStatus == \"delivered\"",
"target": { "kind": "agent", "url": "https://automations.example.com/formahand", "bearer": "…" },
"enabled": true
}
The same as an MCP call: create_subscription { topics: ["order.updated"], filter: 'changed contains "trackingStatus" and data.trackingStatus == "delivered"', target: { kind: "webhook", url } }. Because the filter reads changed, a later re-check that only refreshes trackingCheckedAt does not match, and subscribing to order.delivered instead needs no filter at all.
6. Delivery
An order event has a third destination besides subscriptions and flows: your store's own **order.event function**, if it has one (store functions). It is queued the same way a flow run is, driven on the same schedule, retried on the same backoff, and it answers with the same action vocabulary — so a function is a third kind of subscriber rather than a second write path into your store.
Each matching (subscription, event) pair becomes one delivery: { id, subscriptionId, eventId, attempt, status: "pending" | "delivered" | "failed", nextAttemptAt, lastAttemptAt, responseStatus, error }.
Envelope. CloudEvents 1.0, structured JSON:
{
"specversion": "1.0",
"id": "01K5…", // the event id
"source": "https://shop.example.com", // the store's primary hostname
"type": "order.updated",
"time": "2026-09-14T10:12:03.412Z",
"subject": "order/2d767824-…", // <resource>/<id>
"datacontenttype": "application/json",
"mode": "live", // extension attribute: "live" or "test" (a sandbox's events)
"data": {
"object": { …the order… },
"changed": ["trackingCheckedAt", "trackingEvents", "trackingStatus"],
"previous": { "trackingStatus": "in_transit", … },
"actor": "system:shippo-webhook",
"store": { "id": "[redacted]", "name": "Formahand Supply" }
}
}
Webhook targets receive that object with Content-Type: application/json; agent targets receive a one-element array with Content-Type: application/cloudevents-batch+json and, when set, Authorization: Bearer <bearer>.
Headers on every attempt: webhook-id (the delivery id, stable across retries: use it as the idempotency key), webhook-timestamp (unix seconds), webhook-signature: v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")> per Standard Webhooks, the legacy X-Formahand-Signature: sha256=<hex HMAC-SHA256(secret, body)>, X-Formahand-Event (the type), X-Formahand-Delivery (the delivery id) and User-Agent: Formahand-Events/1. The key is the secret's UTF-8 bytes as given (64 hex characters).
Verification in Node:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyFormahand(secret, headers, rawBody) {
const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5-minute tolerance
const expected = `v1,${createHmac("sha256", secret).update(`${id}.${ts}.${rawBody}`).digest("base64")}`;
const ok = (headers["webhook-signature"] ?? "").split(" ").some((candidate) =>
candidate.length === expected.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)));
// Legacy header, if you still verify the old way:
const legacy = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
const legacyOk = headers["x-formahand-signature"] === legacy;
return ok || legacyOk;
}
// Then: if (seen.has(headers["webhook-id"])) return 200; seen.add(...); handle(JSON.parse(rawBody));
Receivers answer any 2xx; anything else is a failed attempt.
Flow runs. A matching flow (automations) is a delivery too, with a flowId instead of a subscriptionId. Flow runs are never attempted while the write is still being answered: a write that leaves runs due answers with X-Formahand-Deliveries-Due: <n> and Formahand starts them immediately afterwards, and anything left over is picked up every ten minutes.
Attempts and retries. A subscription delivery is attempted immediately after the write, without holding up the request that caused the event. Failures follow the backoff 1 min, 5 min, 30 min, 2 h, 8 h (six attempts in total); after that the delivery is failed. Everything still due — retries, flow runs, waits that have come off — is picked up every ten minutes, oldest first, and retention is applied at the same time. Each attempt has a 10 s timeout and redirects are never followed: a 3xx is a failure. error codes: http_<status>, redirect, timeout, network, invalid_url (the stored URL no longer passes the rules of §12), missing (the subscription or event is gone). POST /api/deliveries/:id/retry puts a pending or failed delivery back to attempt 0 with the full schedule and tries at once. POST /api/subscriptions/:id/test writes a test.ping event and delivers it to that one subscription, ignoring its topics, filter and even enabled, and returns { delivery, event }.
7. Your data stays in your store
- Events, subscriptions, deliveries and echo rows live only in your own store's data. Formahand does not keep a second copy: it authorizes the call, hands it to your store and drives the delivery schedule. An id from another store is a 404.
- Event data never contains secrets. An integration event carries its
configand the names of the secret keys that are set, never their values; payment settings carry the provider and the instructions only; a subscription or flow event drops the webhook secret and the agent bearer; your API tokens and payment credentials are never part of an event. - Failures are logged without your data. A failed delivery is recorded as an attempt with its status code and error code — never the URL, the body or the secret.
8. Echo sink (debugging aid)
Your store serves POST https://<store host>/api/events/echo, and POST https://formahand.com/api/events/echo?store=<store id> reaches the same place. Pointing a subscription at the sink exercises the whole path — signing, retries, status recording — without an external receiver. The sink reads webhook-id, finds that delivery's subscription in your store, recomputes the Standard Webhooks signature with its secret and refuses anything else (401 Unknown delivery. / Bad signature.), so strangers cannot fill it. It keeps the last 20 bodies with their webhook-*, X-Formahand-*, Content-Type and User-Agent headers and signatureValid: true, readable at GET /api/events/echo (session, or settings:read).
A store cannot deliver to itself directly. A subscription targeting https://<store host>/api/events/echo or https://formahand.com/api/events/echo?store= never arrives: Formahand's own domains are refused as outbound targets (§12), and a call that loops straight back to the store it came from does not complete. Put a small forwarder of your own in between — anything that accepts the delivery and re-posts it, headers and body untouched, to https://formahand.com/api/events/echo?store=<store id>. The forwarder needs to keep nothing; the signature is still verified by your store.
9. API
All routes take ?storeId= (GET/DELETE) or storeId in the JSON body for sessions; a token is bound to its store. Errors are { error, field?, position? } (field is topics, url, name or filter).
| Route | Scope | Does |
|---|---|---|
GET /api/events?since=<id|iso>&before=<id>&type=<pattern>&resource=&limit= | events:read | { events: [{ id, type, resource, resourceId, subject, occurredAt, actor, data, changed, previous }], nextCursor, order }. Newest first, limit ≤ 200 (default 50); before=nextCursor pages back. With since (an event id, or an ISO time) the events strictly after that point, oldest first (order: "asc"), for polling consumers |
GET /api/events/:id | events:read | { event } |
GET /api/events/vocabulary | events:read | topics seen with counts, resources, verbs, aliases, topic pattern rules, filter grammar/fields/examples, envelope and retry schedule |
GET /api/events/echo | settings:read | { received: [{ id, receivedAt, headers, body, signatureValid }] } |
GET /api/subscriptions | settings:read | { subscriptions: [SubscriptionView], max: 20 } |
POST /api/subscriptions | settings:write | { name?, topics, filter?, target: { kind: "webhook", url } | { kind: "agent", url, bearer? }, enabled? } → 201 { subscription }; 400 with field, 409 at the limit |
PUT /api/subscriptions | settings:write | { id, name?, topics?, filter?, target?, enabled? } → { subscription }; omitted fields keep their value, the secret is kept, an agent bearer is replaced only when given; 404 |
DELETE /api/subscriptions?id= | settings:write | { success: true }; deliveries cascade |
POST /api/subscriptions/:id/test | settings:write | { delivery, event } after one immediate attempt of a test.ping |
GET /api/subscriptions/:id/deliveries?limit= | settings:read | { deliveries: [{ id, subscriptionId, eventId, eventType, attempt, status, nextAttemptAt, lastAttemptAt, responseStatus, error, createdAt }] }, newest first |
POST /api/deliveries/:id/retry | settings:write | { delivery } after an immediate attempt with a fresh schedule; flow runs too (automations) |
GET /api/deliveries?status=dead&limit= | settings:read | the dead letters of §13 |
GET/PUT/DELETE /api/webhooks | settings:read / settings:write | legacy shim: lists webhook-kind subscriptions in the old shape ({ id, url, secret, events, status, createdAt, lastDeliveryAt, lastStatus }); PUT { id?, url, events, status } creates or updates a subscription with the legacy events mapped to topics (product.updated → product.created + product.updated), status → enabled |
SubscriptionView: { id, name, topics, filter, target: { kind: "webhook", url, secret } | { kind: "agent", url, bearerSet }, enabled, createdAt, updatedAt, lastDeliveryAt, lastStatus, failureCount, disabledReason }.
MCP tools: describe_events (events:read), list_events { since?, before?, type?, resource?, limit? } and get_event { id } (events:read), list_subscriptions and list_deliveries { subscriptionId, limit? } (settings:read), create_subscription, update_subscription, delete_subscription, test_subscription { id }, retry_delivery { id } (settings:write). list_webhooks / upsert_webhook remain as the legacy view. The token is the actor (token:<id>).
10. Dashboard
Automations → the Events and Webhooks tiles (#automations/events, #automations/subscriptions): Events (the last 50, refreshed every 30 s while the part is visible, filterable by topic pattern and resource, each row expandable to changed chips and the data/previous JSON, "Load older" through nextCursor); Webhooks, a list whose rows open one webhook at #automations/subscriptions/<id> (topic chips, filter, target with a revealable webhook secret or bearer set, Active/Paused/Disabled with disabledReason, last delivery and status, failure count; Edit, Pause/Resume, Send test with the inline result, Deliveries, Delete in a dialog); the create/edit form at #automations/subscriptions/new (name, topic tag input with quick-adds from the vocabulary, a filter textarea validated as you type with an examples disclosure, webhook/agent target, enabled); the Deliveries panel per subscription with Retry; and Verify a delivery, the signature summary. Settings → Agents & API has a Webhooks row that points here. The dashboard calls a subscription a webhook; the API and the tools keep the name subscription.
12. Outbound policy
Every URL you or your agent give Formahand to call — a subscription target, a flow's webhook or wake_agent action — is checked when it is saved and again when it is called. Refused, with the reason in error and field naming the input:
| Refused | Why |
|---|---|
anything but https: | plain HTTP would carry your store's data and signatures in the clear |
| a username or password in the URL | credentials belong in a header or the secrets vault |
| longer than 2,048 characters | a bound on what we store and log |
127.0.0.1, 10.*, 172.16–31.*, 192.168.*, 169.254.* (the metadata address), 100.64–127.*, 0.*, 224.*+ | private, loopback and link-local networks a store must not be able to probe |
| any IP literal, IPv6 included | your endpoint always has a name |
localhost, *.local, *.internal, *.lan, *.corp, *.arpa, metadata.google.internal, a bare name with no dot | names that always mean "inside" |
formahand.com, store-on-formahand.app and their subdomains | a store cannot call us back, nor knock on another store; that is what flows are for |
| free per-account subdomains of shared hosting services | never an endpoint of your own, so they cannot be trusted as one |
DNS is not resolved before the check, so a public name that happens to point at a private address is outside what it can see. What bounds that case is the delivery itself: 10 seconds per call, at most 64 KB of any response read (deliveries read none at all — only the status code decides), redirects never followed, and the retry schedule with auto-disable after repeated failures.
13. Dead letters
A delivery that has used its whole retry schedule is marked failed with no next attempt. Those are your dead letters:
GET /api/deliveries?status=dead&limit= (settings:read)
→ { deliveries: [{ id, subscriptionId, flowId, eventId, eventType, attempt, status, error, lastAttemptAt, createdAt }] }
Newest first, limit ≤ 200 (default 50). Subscription deliveries and flow runs are both listed; subscriptionId or flowId says which. POST /api/deliveries/:id/retry (retry_delivery { id }) puts one back with a fresh schedule — for a flow run only the actions that failed run again. Dead rows are kept for the usual 30 days of delivery retention and then removed.