Delete memories programmatically
Overview
Use the Security Delete API to find memories owned by a wallet, select memories older than a cutoff date, and permanently delete them. The API prepares and sponsors the Sui transaction; your wallet authenticates the requests and signs the prepared transaction.
Deletion is permanent. Start with the dry run in this guide, review every blob ID, and only then enable deletion.
The deletion flow uses these endpoints:
| Step | Endpoint | Purpose |
|---|---|---|
| 1 | POST /api/security-delete-auth/challenge | Request a single-use wallet challenge. |
| 2 | POST /api/security-delete-auth/verify | Exchange the signed challenge for a short-lived Bearer token. |
| 3 | GET /api/security-deletable-blobs | List tracked blobs that the wallet can delete. |
| 4 | POST /api/security-deletions | Prepare a sponsored deletion transaction. |
| 5 | POST /api/security-deletions/{batchId}/submit | Submit the wallet signature and execute the deletion. |
Before you begin
You need:
- Node.js 20 or later.
- The base URL of a Walrus Memory deployment with the Security Delete API enabled.
- The Sui private key for the wallet that owns the memories.
The example uses an Ed25519 key. Keep the key in a secret manager or environment variable;
never commit it to source control. For an interactive application, use the connected
wallet's signPersonalMessage and signTransaction methods instead.
Create a small Node.js project and install the Sui SDK:
$ npm install @mysten/sui
$ npm install --save-dev tsx
Create the deletion script
Create delete-old-memories.ts:
import { decodeSuiPrivateKey } from '@mysten/sui/cryptography'
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
import { fromBase64 } from '@mysten/sui/utils'
type Challenge = {
challengeId: string
challenge: string
expiresInSecs: number
}
type BlobItem = {
blobId: string
objectId: string | null
createdAt: string
state: string
}
type BlobPage = {
items: BlobItem[]
limits: { deleteBatchMax: number }
nextCursor: string | null
}
type PreparedBatch = {
batchId: string | null
txBytes: string | null
included: number
excluded: Array<{ blobId: string; reason: string }>
expiresAt: string | null
}
type BatchStatus = {
state: 'awaiting_signature' | 'executing' | 'completed' | 'failed' | 'rolled_back'
blobCount: number
digest: string | null
resolvedAt: string | null
}
const baseUrl = requireEnv('MEMWAL_URL').replace(/\/$/, '')
const privateKey = requireEnv('SUI_PRIVATE_KEY')
const maxAgeDays = positiveNumber(process.env.MEMORY_MAX_AGE_DAYS ?? '90')
const confirmed = process.env.CONFIRM_DELETE === 'true'
const decoded = decodeSuiPrivateKey(privateKey)
if (decoded.scheme !== 'ED25519') {
throw new Error(`This example requires an Ed25519 key, received ${decoded.scheme}`)
}
const signer = Ed25519Keypair.fromSecretKey(decoded.secretKey)
const address = signer.toSuiAddress()
function requireEnv(name: string): string {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required`)
return value
}
function positiveNumber(value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error('MEMORY_MAX_AGE_DAYS must be a positive number')
}
return parsed
}
async function api<T>(path: string, init: RequestInit = {}, token?: string): Promise<T> {
const headers = new Headers(init.headers)
headers.set('accept', 'application/json')
if (init.body) headers.set('content-type', 'application/json')
if (token) headers.set('authorization', `Bearer ${token}`)
const response = await fetch(`${baseUrl}${path}`, { ...init, headers })
const body = await response.json().catch(() => null)
if (!response.ok) {
const error = body?.error
throw new Error(
`${response.status} ${error?.code ?? 'UNKNOWN'}: ${error?.message ?? 'Request failed'}`,
)
}
return body as T
}
async function authenticate(): Promise<string> {
const challenge = await api<Challenge>('/api/security-delete-auth/challenge', {
method: 'POST',
body: JSON.stringify({ address }),
})
// Sign the exact UTF-8 bytes returned by the server. Do not rebuild the message.
const signed = await signer.signPersonalMessage(
new TextEncoder().encode(challenge.challenge),
)
const verified = await api<{ token: string }>('/api/security-delete-auth/verify', {
method: 'POST',
body: JSON.stringify({
challengeId: challenge.challengeId,
address,
signature: signed.signature,
}),
})
return verified.token
}
async function listDeletable(token: string): Promise<{
items: BlobItem[]
batchMax: number
}> {
const items: BlobItem[] = []
let cursor: string | null = null
let batchMax = 900
do {
const query = new URLSearchParams({ state: 'deletable', limit: '200' })
if (cursor) query.set('cursor', cursor)
const page = await api<BlobPage>(
`/api/security-deletable-blobs?${query.toString()}`,
{},
token,
)
items.push(...page.items)
batchMax = Math.min(batchMax, page.limits.deleteBatchMax)
cursor = page.nextCursor
} while (cursor)
return { items, batchMax }
}
async function main() {
const token = await authenticate()
const { items, batchMax } = await listDeletable(token)
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000)
const candidates = items
.filter((item) => new Date(item.createdAt) < cutoff)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
console.log(`Wallet: ${address}`)
console.log(`Cutoff: ${cutoff.toISOString()}`)
console.table(
candidates.map(({ blobId, objectId, createdAt }) => ({ blobId, objectId, createdAt })),
)
if (candidates.length === 0) {
console.log('No deletable memories are older than the cutoff.')
return
}
if (!confirmed) {
console.log('Dry run only. Set CONFIRM_DELETE=true after reviewing the list.')
return
}
let deleted = 0
for (let offset = 0; offset < candidates.length; offset += batchMax) {
const blobIds = candidates.slice(offset, offset + batchMax).map((item) => item.blobId)
const prepared = await api<PreparedBatch>(
'/api/security-deletions',
{
method: 'POST',
body: JSON.stringify({ mode: 'selection', blobIds }),
},
token,
)
if (!prepared.batchId || !prepared.txBytes) {
console.log('The batch contained nothing that remained deletable.')
continue
}
if (prepared.excluded.length > 0) {
console.table(prepared.excluded)
}
// txBytes is the exact sponsored TransactionData prepared by the server.
const signed = await signer.signTransaction(fromBase64(prepared.txBytes))
try {
const result = await api<{ deleted: number; digest: string }>(
`/api/security-deletions/${encodeURIComponent(prepared.batchId)}/submit`,
{
method: 'POST',
body: JSON.stringify({ signature: signed.signature }),
},
token,
)
deleted += result.deleted
console.log(`Deleted ${result.deleted} memories in transaction ${result.digest}`)
} catch (submitError) {
// A transport error can leave execution outcome uncertain. Check the batch before
// preparing a replacement transaction for the same blobs.
const status = await api<BatchStatus>(
`/api/security-deletions/${encodeURIComponent(prepared.batchId)}`,
{},
token,
)
console.error(`Batch ${prepared.batchId} is ${status.state}.`, submitError)
throw new Error('Deletion stopped; resolve this batch before retrying.')
}
}
console.log(`Deleted ${deleted} memories in total.`)
}
await main()
Run a dry run
Set the API URL, wallet key, and age cutoff. The script only lists candidates unless
CONFIRM_DELETE is exactly true.
$ export MEMWAL_URL="https://your-memwal-deployment.example"
$ export SUI_PRIVATE_KEY="suiprivkey1..."
$ export MEMORY_MAX_AGE_DAYS="90"
$ npx tsx delete-old-memories.ts
Review the wallet address, cutoff timestamp, blob IDs, object IDs, and creation dates in the output.
Delete the reviewed memories
After you confirm the dry-run output, enable deletion and run the same script again:
$ export CONFIRM_DELETE="true"
$ npx tsx delete-old-memories.ts
The script chunks the selection using the server's deleteBatchMax value. Each batch
requires a newly prepared transaction and wallet signature.
Verify the deletion
Run the script again without CONFIRM_DELETE. Successfully deleted blobs no longer appear
in the default deletable list. To inspect terminal states directly, request them with the
same Bearer token:
GET /api/security-deletable-blobs?state=deleted,deleted_external&limit=200
Authorization: Bearer <token>
If submitting a batch times out or returns RPC_UNAVAILABLE, query
GET /api/security-deletions/{batchId} until it reaches a terminal state. Do not prepare a
replacement batch for the same blobs while its state is executing.
To delete memories through the dashboard instead, see Delete old memories.