Skip to main content

API Reference

REST API Endpoints

Core API endpoints for the Amply platform.

Base Information

URLs

EnvironmentBase URL
Productionhttps://api.amply-impact.org/v1
Staginghttps://api.staging.amply-impact.org/v1

Authentication

Session auth (dashboard users):

Cookie: session=xxx

API keys (integrators):

Authorization: Bearer sk_live_xxx

Key prefixes:

  • sk_live_ - Production keys
  • sk_test_ - Staging/sandbox keys

Response Format

Success:

{
"data": { ... },
"meta": {
"request_id": "req_abc123"
}
}

Error:

{
"error": {
"code": "invalid_request",
"message": "Human-readable message",
"param": "field_name"
}
}

Pagination

GET /donations?limit=50&cursor=cur_xxx

Response:

{
"data": [...],
"has_more": true,
"next_cursor": "cur_yyy"
}

Public Endpoints

No authentication required. Read-only access to public ledger data.

List Organisations

GET /public/organisations

Query Parameters:

ParamTypeDescription
limitintResults per page (default 20, max 100)
cursorstringPagination cursor
countrystringFilter by country code

Response:

{
"data": [
{
"id": "org_xyz789",
"name": "Example Charity",
"country": "DE",
"status": "verified",
"profile": {
"description": "...",
"website": "https://...",
"logo_url": "https://..."
}
}
],
"has_more": false
}

Get Organisation

GET /public/organisations/:id

Get Public Ledger

GET /public/organisations/:id/ledger

Query Parameters:

ParamTypeDescription
limitintEntries per page (default 50)
cursorstringPagination cursor
start_datedateFilter from date
end_datedateFilter to date

Response:

{
"data": [
{
"id": "led_abc123",
"type": "donation_received",
"amount": 5000,
"currency": "EUR",
"created_at": "2025-01-15T14:30:00Z",
"metadata": {
"donation_id": "don_xyz789",
"stripe_payment_intent_id": "pi_abc123...",
"donor_name": "Jane Donor"
},
"entry_hash": "sha256:abc123...",
"prev_entry_hash": "sha256:xyz789..."
}
],
"has_more": true,
"next_cursor": "cur_xxx"
}

Export Ledger

GET /public/organisations/:id/ledger/export

Returns complete ledger as downloadable JSON for independent verification.

Response:

{
"downloaded_at": "2025-01-15T12:00:00Z",
"organisation_id": "org_xyz789",
"entry_count": 1234,
"entries": [
{
"id": "led_000001",
"timestamp": "2025-01-01T00:00:00Z",
"organisation_id": "org_xyz789",
"type": "donation_received",
"amount": 5000,
"currency": "EUR",
"metadata": {
"donation_id": "don_abc123",
"stripe_payment_intent_id": "pi_xyz789...",
"donor_name": "Jane Donor"
},
"prev_entry_hash": null,
"entry_hash": "sha256:..."
}
]
}

Note: The stripe_payment_intent_id in metadata enables third-party verification against Stripe records.

Get Checkpoint

GET /public/checkpoints/:id

Response:

{
"data": {
"id": "chk_2025-01-15",
"checkpoint_date": "2025-01-15",
"cumulative_hash": "sha256:abc123...",
"entry_count": 15842,
"total_volume": 284729340
}
}

List Checkpoints

GET /public/checkpoints

Donations

Create Donation

POST /donations

Request Body:

{
"organisation_id": "org_xyz",
"fund_id": "fund_abc",
"amount": 5000,
"currency": "EUR",
"donor_email": "donor@example.com",
"donor_name": "Jane Donor"
}

Response:

{
"data": {
"id": "don_abc123",
"status": "pending",
"client_secret": "pi_xxx_secret_yyy"
}
}

Use client_secret with Stripe.js to complete payment.

Get Donation

GET /donations/:id

Requires authentication. Returns donation details.

List Donations

GET /donations

Requires authentication. Returns donations for authenticated user or organisation.


Organisations (Authenticated)

Get My Organisations

GET /me/organisations

Returns organisations the authenticated user belongs to.

Get Organisation Details

GET /organisations/:id

Requires membership in the organisation.

Update Organisation

PATCH /organisations/:id

Requires admin role.

Get Stripe Connect URL

GET /organisations/:id/stripe/connect

Returns URL to initiate Stripe Connect onboarding.


Auth

Login

POST /auth/login

Request Body:

{
"email": "user@example.com",
"password": "xxx"
}

Response: Sets session cookie and returns user info.

{
"data": {
"user": {
"id": "usr_abc123",
"email": "user@example.com",
"name": "Jane User"
},
"session": {
"id": "ses_xyz789",
"expires_at": "2025-01-22T14:30:00Z"
}
}
}

Logout

POST /auth/logout

Ends the current session.

Logout Everywhere

POST /auth/logout-all

Invalidates all sessions by rotating the user's security stamp. Forces re-authentication on all devices.

Response:

{
"data": {
"sessions_revoked": 4,
"message": "All sessions have been revoked"
}
}

Get Current User

GET /auth/me

Response:

{
"data": {
"id": "usr_abc123",
"email": "user@example.com",
"name": "Jane User",
"security_settings": {
"ip_binding": "country",
"session_timeout_days": 7,
"notify_new_device": true
}
}
}

List Active Sessions

GET /auth/sessions

Returns all active sessions for the current user. Useful for "Active Sessions" UI.

Response:

{
"data": [
{
"id": "ses_abc123",
"created_at": "2025-01-10T09:00:00Z",
"last_activity": "2025-01-15T14:30:00Z",
"client": {
"ip_address": "203.0.113.45",
"ip_country": "DE",
"ip_city": "Berlin",
"browser_family": "Chrome",
"os_family": "Windows",
"device_type": "desktop"
},
"is_current": true
},
{
"id": "ses_xyz789",
"created_at": "2025-01-08T16:20:00Z",
"last_activity": "2025-01-14T11:00:00Z",
"client": {
"ip_address": "198.51.100.22",
"ip_country": "DE",
"ip_city": "Munich",
"browser_family": "Safari",
"os_family": "iOS",
"device_type": "mobile"
},
"is_current": false
}
]
}

Revoke Session

DELETE /auth/sessions/:session_id

Revokes a specific session. Cannot revoke current session (use /auth/logout instead).

Response:

{
"data": {
"revoked": true,
"session_id": "ses_xyz789"
}
}

Change Password

POST /auth/password

Changes password and rotates security stamp, invalidating all other sessions.

Request Body:

{
"current_password": "xxx",
"new_password": "yyy"
}

Response:

{
"data": {
"changed": true,
"sessions_revoked": 3,
"message": "Password changed. Other sessions have been logged out."
}
}

Update Security Settings

PATCH /auth/security-settings

Updates user's security preferences.

Request Body:

{
"ip_binding": "subnet",
"session_timeout_days": 14,
"notify_new_device": true
}

IP Binding Options:

ValueDescription
noneNo IP validation
countryAlert on country change (default)
subnetAlert on subnet change
strictAlert on any IP change

Webhooks

Stripe Webhook

POST /webhooks/stripe

Receives Stripe webhook events. Verified using Stripe-Signature header.

Handled Events:

EventAction
payment_intent.succeededComplete donation, create ledger entry
payment_intent.payment_failedMark donation as failed

Rate Limiting

All endpoints (except webhooks and health checks) are rate limited to protect the platform and ensure fair usage.

Rate Limit Tiers

Anonymous/Public (by IP)

EndpointLimitWindow
General public API60/minSliding
Donation initiation5/minSliding
Ledger viewing30/minSliding
Ledger export3/hourFixed
Widget embed300/minSliding

Authenticated (by User ID)

EndpointLimitWindow
General dashboard API200/minSliding
Donation creation20/minSliding
Settings changes10/minSliding
Password changes3/hourFixed

Organisation API Keys

EndpointLimitWindow
Read operations500/minSliding
Write operations100/minSliding

Exempt (No Limits)

  • POST /webhooks/stripe — Critical payment flow
  • GET /health, GET /ready — Infrastructure probes

Response Headers

All responses include rate limit headers:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1704067200

Rate Limit Exceeded (429)

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0

{
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests. Retry after 30 seconds.",
"retry_after": 30
}
}

Implementation

Rate limiting uses sliding window algorithm with Redis:

# Identifier priority
1. User ID (if authenticated)
2. API Key (if present)
3. IP address (fallback)

# Redis key format
ratelimit:{identifier}:{endpoint}:{window}

Card Testing Prevention

Donation endpoints have multi-layer protection:

LayerCheckLimit
IPRequests from single IP5/min
CardSame card fingerprint3/hour
Email domainDisposable email domains50/hour
GlobalPlatform-wide donations1000/min

Widget Burst Handling

Widgets support burst traffic for viral embeds:

TierLimitPurpose
Burst100/10secHandle traffic spikes
Sustained500/minPrevent abuse

Rate Limit Monitoring

Metrics Emitted

All rate limit events emit CloudWatch metrics:

MetricTypeDimensions
RateLimitCheckedCounterendpoint, identifier_type
RateLimitBlockedCounterendpoint, identifier_type, reason
RateLimitRemainingGaugeendpoint

Detecting Attacks vs Legitimate Traffic

Attack Indicators

PatternIndicatorResponse
Single IP flood>50 blocks/min from one IPAuto-block IP
Distributed attack>100 blocks/min on one endpointAlert security
Card testingMultiple card fingerprints, same IPBlock + alert
Credential stuffingHigh /auth/login blocksCAPTCHA trigger

Legitimate High Traffic Indicators

PatternIndicatorResponse
Widget viralHigh widget traffic, valid referrersAuto-scale limits
Geographic spikeClustered region, organic growthMonitor only
API integrationSingle API key, consistent patternsContact for upgrade

CloudWatch Alarms

# High block rate (potential attack)
HighBlockRate:
Metric: RateLimitBlocked
Threshold: 100
Period: 60
Action: SNS → security-alerts

# Single IP abuse
SingleIPAbuse:
Metric: RateLimitBlocked
Dimensions:
identifier_type: ip
Threshold: 50
Period: 60
Action: Lambda → auto_block_ip

# Widget traffic spike (may need scaling)
WidgetSpike:
Metric: RateLimitBlocked
Dimensions:
endpoint: /widgets/*
Threshold: 200
Period: 300
Action: SNS → ops-alerts

# Donation endpoint pressure (card testing?)
DonationPressure:
Metric: RateLimitBlocked
Dimensions:
endpoint: /donations
Threshold: 20
Period: 60
Action: SNS → security-alerts + Lambda → enhanced_logging

Dashboard Queries

Top Blocked IPs (Last Hour)

SELECT
ip_address,
COUNT(*) as block_count,
array_agg(DISTINCT endpoint) as endpoints
FROM rate_limit_logs
WHERE blocked = true
AND timestamp > NOW() - INTERVAL '1 hour'
GROUP BY ip_address
ORDER BY block_count DESC
LIMIT 20

Block Rate by Endpoint

SELECT
endpoint,
COUNT(*) FILTER (WHERE blocked) as blocked,
COUNT(*) as total,
ROUND(100.0 * COUNT(*) FILTER (WHERE blocked) / COUNT(*), 2) as block_rate
FROM rate_limit_logs
WHERE timestamp > NOW() - INTERVAL '1 hour'
GROUP BY endpoint
ORDER BY block_rate DESC

Widget Traffic by Referrer

SELECT
referrer_domain,
COUNT(*) as requests,
COUNT(*) FILTER (WHERE blocked) as blocked,
MAX(timestamp) as last_seen
FROM rate_limit_logs
WHERE endpoint LIKE '/widgets/%'
AND timestamp > NOW() - INTERVAL '24 hours'
GROUP BY referrer_domain
ORDER BY requests DESC
LIMIT 50

Automatic Responses

# Auto-block abusive IPs
async def auto_block_ip(ip: str, reason: str):
"""Add IP to block list for 24 hours."""
await redis.setex(
f"blocked_ip:{ip}",
86400, # 24 hours
json.dumps({"reason": reason, "blocked_at": datetime.utcnow().isoformat()})
)

# Log for security review
logger.warning(f"Auto-blocked IP {ip}: {reason}")

# Notify security team
await send_alert(
channel="security",
message=f"IP auto-blocked: {ip}\nReason: {reason}"
)

# Middleware checks block list before rate limiting
async def check_blocked(request: Request):
ip = get_client_ip(request)
if await redis.exists(f"blocked_ip:{ip}"):
raise HTTPException(403, "Access denied")

Legitimate Traffic Handling

# Detect widget going viral
async def check_widget_viral(widget_id: str, referrer: str):
"""
Detect legitimate viral traffic and auto-adjust limits.

Indicators of legitimate traffic:
- Valid, consistent referrer domain
- Organic growth pattern (not instant spike)
- Geographic distribution matches referrer audience
"""
key = f"widget_traffic:{widget_id}:{referrer}"

# Track request pattern
await redis.hincrby(key, "count", 1)
await redis.expire(key, 3600)

count = int(await redis.hget(key, "count") or 0)

# If traffic is high but from valid referrer, increase limits
if count > 100 and await is_valid_referrer(referrer):
elevated_key = f"widget_elevated:{widget_id}"
await redis.setex(elevated_key, 3600, "true")

logger.info(
f"Elevated widget limits for {widget_id}",
extra={"referrer": referrer, "count": count}
)

def get_widget_limit(widget_id: str) -> tuple[int, int]:
"""Get rate limit for widget, considering elevated status."""
if redis.exists(f"widget_elevated:{widget_id}"):
return (1000, 60) # Elevated: 1000/min
return (300, 60) # Normal: 300/min

HTTP Status Codes

CodeMeaning
200Success
201Created
400Bad Request
401Unauthorized
403Forbidden
404Not Found
429Too Many Requests (rate limited)
500Server Error

Error Codes

CodeDescription
invalid_requestMissing or invalid parameters
authentication_errorInvalid session or API key
authorization_errorInsufficient permissions
not_foundResource doesn't exist
rate_limit_exceededToo many requests
payment_errorPayment processing failed

Related: