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 shapeCan you replay it?How
Idempotent, driven by an external sourceyesre-consume from the queue or the source system
Append-only events with natural keysyescopy rows the restored database doesn’t have
Money movement with an external side effectno — don’t replayreconcile against the payment provider, by hand
Derived or cached datadon’t botherrecompute from source
User-generated contentusuallycopy 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.

References

Browse more db developer guides