Rolling back a bad deploy without losing the writes since
Restore gives you the old data in a new database. The hard part is reconciling the writes that happened after the snapshot — with a decision table for each write shape.
The bad deploy shipped at 14:00, you noticed at 14:40, and your Infrai snapshot is from 13:55. POST /v1/db/snapshot/restore/{snapshot_id} will give you the 13:55 data as a new project — but the orders, signups and messages from those 45 minutes are only in the damaged database. Restoring blindly loses them.
This page is about that gap, because the API call is the easy part and the reconciliation is where the judgement lives.
What restore actually gives you
curl -sS -X POST "https://api.infrai.cc/v1/db/snapshot/restore/dbs_4kQ9mVzR1sXbNt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"snapshot_id": "dbs_4kQ9mVzR1sXbNt", "new_name": "checkout-restore-1440"}'
{
"ok": true,
"data": {
"project_id": "dbp_9wQ1zV6pLkS3dHyB",
"db_id": "db_2fVc8nRqLmT4xBzY",
"name": "checkout-restore-1440",
"vendor": "tencent_pg",
"plan": "hobby",
"state": "ready",
"postgres_version": "17",
"primary_branch": "main",
"created_at": "2026-09-21T14:41:02Z"
}
}
A new project, alongside the damaged one. Both exist; nothing was overwritten.
That’s deliberate and it’s what makes reconciliation possible at all — you can query both databases at once and decide what to carry across. An in-place restore would have destroyed the evidence.
Decide per write shape, not per table
The question isn’t “can we recover the 45 minutes”. It’s “what kind of writes were they”, and the answer differs:
| Write shape | Can you replay it? | How |
|---|---|---|
| Idempotent, driven by an external source | yes | re-consume from the queue or the source system |
| Append-only events with natural keys | yes | copy rows the restored database doesn’t have |
| Money movement with an external side effect | no — don’t replay | reconcile against the payment provider, by hand |
| Derived or cached data | don’t bother | recompute from source |
| User-generated content | usually | copy by primary key, then check for conflicts |
The third row is the one that sends teams wrong. Replaying a charge because a row is missing is how one customer gets billed twice, and the payment provider — not your database — is the authority on what happened.
Keep the queue as your replay log
This is the architecture that makes rollbacks survivable, and it’s worth adopting before you need it. If writes arrive through POST /v1/queue/publish and your worker only acknowledges with POST /v1/queue/ack after the database commits, then the messages for anything uncommitted are still there:
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue": "orders", "max_messages": 10, "visibility_timeout": 60}'
Point the worker at the restored database and let it drain. The writes that were lost to the restore come back through the same path they arrived by, with the same idempotency keys, so a message processed twice doesn’t double anything.
Without that, your replay source is whatever your logs happen to contain — GET /v1/logs/search is better than nothing and much worse than a queue.
The cutover checklist
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def restore_and_wait(snapshot_id: str, new_name: str, timeout_seconds: int = 900) -> dict:
"""Restore, then wait for 'ready'. Repointing the application at a project that
is still provisioning turns a controlled rollback into a second incident."""
resp = SESSION.post(f"{API}/v1/db/snapshot/restore/{snapshot_id}",
json={"snapshot_id": snapshot_id, "new_name": new_name}, timeout=120)
resp.raise_for_status()
project = resp.json()["data"]
deadline = time.monotonic() + timeout_seconds
while project.get("state") != "ready" and time.monotonic() < deadline:
time.sleep(5)
check = SESSION.get(f"{API}/v1/db/project/get/{project['project_id']}", timeout=25)
check.raise_for_status()
project = check.json()["data"]
if project.get("state") != "ready":
raise TimeoutError(f"{project['project_id']} is {project.get('state')}")
return project
def audit_trail(note: str, project_id: str) -> None:
"""Write the rollback into the log stream so the post-mortem has timestamps
nobody had to remember."""
SESSION.post(f"{API}/v1/logs/ingest",
json={"entries": [{"level": "warn", "message": note,
"attributes": {"restored_project_id": project_id}}]},
timeout=20)
if __name__ == "__main__":
restored = restore_and_wait(os.environ["SNAPSHOT_ID"], "checkout-restore-1440")
audit_trail("rolled back deploy 0042 by snapshot restore", restored["project_id"])
print(f"repoint the application at {restored['project_id']} ({restored['db_id']})")
Then, in order: stop the writers, point the application at the restored project, replay what’s replayable, reconcile what isn’t by hand, and keep the damaged project around until the post-mortem is written. Deleting the evidence on the day is a decision you’ll regret in the review.
The limitation to accept before you rely on this
There’s no point-in-time recovery here. You get the snapshots you took, so the size of the gap is a function of how often you snapshot — and “we snapshot before every migration” gives you a good rollback for migrations and no rollback at all for a data bug that shipped on a Tuesday afternoon.
If your requirement is genuinely “restore to any second in the last week”, this isn’t a good fit and a managed Postgres with continuous archiving is what you want. Snapshots plus a durable queue cover a surprising amount of the same ground, but they are not the same guarantee and you should not plan as if they were.
What you do get is that every part of the response lives on one credential: the snapshot, the restore, the queue you replay from, the log lines that timestamp it and the POST /v1/email/send that tells the team — no second vendor to log into during an incident, and one GET /v1/account/usage afterwards showing what the recovery cost. Snapshot and restore rates are live in GET /v1/discovery/db.snapshot.restore (verified 2026-09-21) and trend downward as vendor contracts improve.