Skip to main content

Delete memories programmatically

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, which means the server pays the gas fees. Your wallet authenticates the requests and signs the prepared transaction.

warning

Deletion is permanent. Start with the dry run in this guide, review every blob ID, and only then enable deletion.

Which endpoints does the deletion flow use?​

The deletion flow uses these endpoints:

StepEndpointPurpose
1POST /api/security-delete-auth/challengeRequest a single-use wallet challenge.
2POST /api/security-delete-auth/verifyExchange the signed challenge for a short-lived Bearer token.
3GET /api/security-deletable-blobsList tracked blobs that the wallet can delete.
4POST /api/security-deletionsPrepare a sponsored deletion transaction.
5POST /api/security-deletions/{batchId}/submitSubmit the wallet signature and execute the deletion.

What does the script require?​

You need:

  • Node.js 22.23.4+ (22.x), 24.22.1+ (24.x), or 26.12.1+ (26.x).
  • 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:

Source: guides/delete-memories-programmatically.md
$ npm install @mysten/sui
$ npm install --save-dev tsx

Create the deletion script​

Create delete-old-memories.ts:

Source: guides/delete-memories-programmatically.md
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.

Source: guides/delete-memories-programmatically.md
$ 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:

Source: guides/delete-memories-programmatically.md
$ 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.

How to 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:

Source: guides/delete-memories-programmatically.md
GET /api/security-deletable-blobs?state=deleted,deleted_external&limit=200
Authorization: Bearer <token>

Replace <token> with the Bearer token that POST /api/security-delete-auth/verify returns. In the script, the authenticate function returns this 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.

Node.js version requirements​

The Node.js minimums of 22.23.4+ within 22.x, 24.22.1+ within 24.x, and 26.12.1+ within 26.x are security-patch levels, not API compatibility limits. They come from the October 2026 Node.js security release. The engines field in Walrus Memory packages describes API compatibility only, so an older patch release can install without warnings and still miss required security fixes.

How long is the Bearer token valid?​

The POST /api/security-delete-auth/verify response includes an expiresInSecs field next to token. The API reference example returns "expiresInSecs": 2700, which is 45 minutes. Long-running jobs should read this field and track when the token expires.

The API returns AUTH_TOKEN_EXPIRED for expired tokens, and also for malformed or tampered ones. If you receive this error, request a new challenge and verify it again to get a fresh token. Each challenge ID works only once, but challenges for the same wallet are independent of each other.