Skip to main content

Data Transfer

Learn how encrypted data is transferred between your application and Unforgettable.app.

Overview

The data transfer mechanism is a temporary, encrypted messaging system that enables secure key exchange between your application and Unforgettable.app without exposing sensitive data to the API.

Transfer Lifecycle

Transfer ID

Generation

The transfer ID is a UUID v4 generated by the SDK:

import { v4 as uuid } from 'uuid'

const dataTransferId = uuid()
// Example: "550e8400-e29b-41d4-a716-446655440000"

Properties:

  • Format: UUID v4 (RFC 4122)
  • Length: 36 characters (32 hex + 4 hyphens)
  • Uniqueness: ~5.3×10³⁶ possible values
  • Collision Probability: Negligible

Purpose

  • Uniquely identifies each recovery session
  • Links SDK instance to Unforgettable.app session
  • Enables API to route encrypted data correctly

API Endpoints

Base URL

https://api.unforgettable.app

Endpoints

GET /integrations/helper-keeper/v1/public/data-transfers/{id}

Retrieve encrypted data for a transfer.

Request:

GET /integrations/helper-keeper/v1/public/data-transfers/550e8400-e29b-41d4-a716-446655440000
Accept: application/json

Response (Success):

{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"data": "base64url_encoded_encrypted_payload"
}
}

Response (Not Found):

HTTP/1.1 404 Not Found

POST /integrations/helper-keeper/v1/public/data-transfers/{id}

Create a new data transfer (called by Unforgettable.app).

Request:

POST /integrations/helper-keeper/v1/public/data-transfers/550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
"data": "base64url_encoded_encrypted_payload"
}

Response:

{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"data": "base64url_encoded_encrypted_payload"
}
}

Polling Strategy

Basic Polling

async function pollForRecoveryKey(
sdk: UnforgettableSdk,
interval: number = 3000,
maxAttempts: number = 60
): Promise<string> {
let attempts = 0

while (attempts < maxAttempts) {
try {
const recoveryKey = await sdk.getRecoveredKey()
return recoveryKey
} catch (error) {
if (error instanceof NotFoundError) {
attempts++
await sleep(interval)
} else {
throw error
}
}
}

throw new Error('Polling timeout')
}

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

Exponential Backoff (Advanced)

For more efficient polling with reduced server load:

async function pollWithBackoff(
sdk: UnforgettableSdk,
initialInterval: number = 1000,
maxInterval: number = 10000,
maxAttempts: number = 60
): Promise<string> {
let attempts = 0
let interval = initialInterval

while (attempts < maxAttempts) {
try {
const recoveryKey = await sdk.getRecoveredKey()
return recoveryKey
} catch (error) {
if (error instanceof NotFoundError) {
attempts++
await sleep(interval)
// Exponentially increase interval up to max
interval = Math.min(interval * 1.5, maxInterval)
} else {
throw error
}
}
}

throw new Error('Polling timeout')
}

Payload Structure

Encrypted Payload

The data field contains a base64url-encoded encrypted JSON object:

Before Encryption:

{
"recovery_key": "0x1234567890abcdef...",
}

After Encryption:

Base64URL([ephemeral_pk][nonce][encrypted_json][auth_tag])

Decryption

// SDK handles this automatically
const { recoveryKey } = await sdk.getRecoveredData()

Manual Decryption (internal):

const encryptedData = base64UrlToBytes(data.data)
const ephemeralPublicKey = encryptedData.slice(0, 32)
const nonce = encryptedData.slice(32, 44)
const ciphertextWithTag = encryptedData.slice(44)

const sharedSecret = x25519.getSharedSecret(privateKey, ephemeralPublicKey)
const encryptionKey = deriveKey(sharedSecret)

const cipher = chacha20poly1305(encryptionKey, nonce)
const plaintextJson = cipher.decrypt(ciphertextWithTag)

const payload = JSON.parse(bytesToString(plaintextJson))

Error Handling

HTTP Status Codes

CodeMeaningAction
200Data availableDecrypt and return
404Data not foundContinue polling
429Rate limitedBack off and retry
500Server errorRetry with backoff
503Service unavailableRetry later

SDK Error Types

import { NotFoundError } from '@rarimo/unforgettable-sdk'

try {
const key = await sdk.getRecoveredKey()
} catch (error) {
if (error instanceof NotFoundError) {
// Data not ready, continue polling
} else if (error.name === 'NetworkError') {
// Network issue, retry
} else if (error.name === 'CryptoError') {
// Decryption failed, abort
} else {
// Unknown error
}
}

Security Considerations

Data Persistence

  • Temporary Storage: Data deleted after TTL (default: 24 hours)
  • One-Time Retrieval: Data can be retrieved multiple times (SDK limitation)
  • No Logging: Encrypted data not logged by API

Transport Security

  • HTTPS Only: All API communication over TLS 1.2+
  • Certificate Pinning: Optional for mobile apps
  • HSTS: Enforced on API endpoints

Rate Limiting

API implements rate limiting to prevent abuse:

  • Per IP: 100 requests/minute
  • Per Transfer ID: 20 requests/minute
  • Burst: 10 requests/second

Handling Rate Limits:

async function pollWithRateLimit(sdk: UnforgettableSdk) {
try {
return await sdk.getRecoveredKey()
} catch (error) {
if (error.status === 429) {
const retryAfter = error.headers['Retry-After'] || 60
await sleep(retryAfter * 1000)
return pollWithRateLimit(sdk)
}
throw error
}
}

Performance Optimization

Polling Interval Recommendations

Use CaseIntervalMax Attempts
Interactive UI2-3 seconds60 (3 minutes)
Background Task5-10 seconds144 (12 minutes)
Low Priority30 seconds40 (20 minutes)

Network Efficiency

Minimize Requests:

// Good: Exponential backoff
let interval = 1000
while (polling) {
await poll()
await sleep(interval)
interval = Math.min(interval * 1.5, 10000)
}

// Bad: Fixed high-frequency polling
while (polling) {
await poll()
await sleep(500) // Too frequent!
}

Connection Reuse:

// Configure HTTP client with keep-alive
const client = new JsonApiClient({
baseUrl: 'https://api.unforgettable.app',
headers: {
'Connection': 'keep-alive',
},
})

Data Transfer Best Practices

1. Implement Timeout

Always set a maximum polling duration:

const MAX_POLLING_TIME = 5 * 60 * 1000 // 5 minutes
const startTime = Date.now()

while (Date.now() - startTime < MAX_POLLING_TIME) {
// Polling logic
}

2. User Feedback

Show polling status to users:

let attempts = 0
while (polling) {
updateUI(`Waiting for recovery... (${attempts}/60)`)
await poll()
attempts++
}

3. Graceful Degradation

Handle network failures gracefully:

try {
return await sdk.getRecoveredKey()
} catch (error) {
if (isNetworkError(error)) {
showRetryButton()
} else {
showErrorMessage(error)
}
}

4. Cancel Polling

Allow users to cancel:

const abortController = new AbortController()

cancelButton.addEventListener('click', () => {
abortController.abort()
})

while (!abortController.signal.aborted) {
await poll()
}

Monitoring and Debugging

Logging

Log polling attempts for debugging:

console.log(`[Poll #${attempts}] Checking for data...`)
console.log(`Transfer ID: ${dataTransferId}`)
console.log(`Elapsed: ${Date.now() - startTime}ms`)

Metrics

Track key metrics:

const metrics = {
totalAttempts: 0,
successTime: 0,
failureRate: 0,
averageLatency: 0,
}

// Update on each poll
metrics.totalAttempts++
metrics.averageLatency = (metrics.averageLatency + latency) / 2

Next Steps