Developer platform
API reference
A REST API over the same permission model as the interface, and outbound webhooks for the events you care about. Everything below is the contract the product’s own interface is built against.
Base URL
The API is served from your own workspace origin. There is no separate API host to allow-list.
https://app.ecoattest.com/api/v1Every path below is relative to that base. The version is in the path, and a breaking change ships as a new version rather than as a change to this one.
Authentication
Two ways in. Use an API key for anything running unattended; use a session token only when you are genuinely acting as a person.
API keys — for integrations
Issue a key from Settings → API keys. The key is shown once, at creation, and only a hash is stored — we cannot recover it for you, so put it straight into your secret store. Keys begin gp_ and are revocable at any time, immediately.
# An API key authenticates with the X-API-Key header.
curl "https://app.ecoattest.com/api/v1/projects" \
-H "X-API-Key: gp_your_key_here"Session tokens — for user-facing tools
A short-lived bearer token from a login. Appropriate when a real person is driving; wrong for a nightly job, because it expires and because the actions are recorded against that person rather than against the integration.
# A short-lived token for a user session, when acting as a person
# rather than as an integration.
curl -X POST "https://app.ecoattest.com/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"...","tenant":"your-slug"}'
# → {"access_token": "eyJhbGciOi...", "token_type": "bearer"}
curl "https://app.ecoattest.com/api/v1/projects" \
-H "Authorization: Bearer eyJhbGciOi..."Scopes and least privilege
A key carries an explicit set of permissions, and it can never hold one its issuer does not.
Scopes are the same permission codes the interface uses — project:read, visit:approve, and so on. A key is refused at creation if it asks for anything beyond what its issuer holds, which closes the most common way an integration quietly becomes a privilege escalation: a low-privilege user minting a high-privilege key.
Grant only what the integration reads. A key that only pulls figures for a dashboard should hold read scopes and nothing else — then a leaked key cannot approve a visit, and everything it did is attributable to it in the audit trail rather than to a person.
Errors
One envelope on every endpoint, carrying a stable code and the request id to quote at us.
{
"error": {
"code": "permission_denied",
"message": "Insufficient permissions",
"details": { "required": ["project:create"] },
"request_id": "aab661a7-4ce3-4468-9787-0d828ea4fc10"
}
}Branch on code, never on the message — messages are written for people and get reworded. The codes you will meet are unauthenticated, permission_denied, not_found, conflict, validation_error and rate_limited.
request_id identifies the exact request in our logs. Quoting it turns “the API returned an error” into something answerable in minutes.
Pagination
List endpoints take limit and offset and return a bare JSON array.
# List endpoints take limit and offset. limit defaults to 50, maximum 200.
curl "https://app.ecoattest.com/api/v1/projects?limit=2" -H "X-API-Key: gp_..."
# → [ { "id": "...", "name": "Riverbank Restoration", ... },
# { "id": "...", "name": "Lakeside Plantation", ... } ]
curl "https://app.ecoattest.com/api/v1/projects?limit=2&offset=2" -H "X-API-Key: gp_..."limit defaults to 50 and caps at 200. There is no envelope and no total, so the end of the data is a page shorter than the one you asked for.
Rate limits
Build the client that handles them, whether or not the endpoint you are calling enforces one today.
Rate limits are applied per endpoint. Public, unauthenticated endpoints — form submissions and event capture — are limited today; authenticated API endpoints are not currently, and that may change without being a breaking change, because tightening a limit is not a contract change.
So write the client that copes either way. Where a limit applies, the response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and a refusal is 429 with Retry-After in seconds.
Honour Retry-After rather than retrying immediately, and back off exponentially on 5xx. A client that retries hard against a struggling service is the reason it stays struggling. Pull on a schedule rather than in a tight loop — for most integrations a nightly or hourly sweep is both sufficient and kinder than polling.
Data conventions
The same four rules hold everywhere, so a field never means something different on another endpoint.
- Timestamps. ISO-8601, always UTC, always with an offset.
- Identifiers. UUIDs. Never assume an ordering or a sequence from one.
- Money. Integer minor units plus a currency code — 249900 with INR is ₹2,499. No floats touch a monetary figure.
- Absent versus zero. null means not measured; 0 means measured as none. A site never visited has a null survival rate, not 0% — the difference is the whole point of the platform.
A worked integration
Pulling verified plantation figures into your own reporting — the thing most integrations are actually for.
"""Pull verified plantation figures into your own reporting.
Uses only approved visits, which is what the platform treats as evidence: a count
that has not been reviewed is a claim, not a measurement.
"""
projects = client.get("/projects", params={"limit": 200}).json()
for project in projects:
sites = client.get(f"/projects/{project['id']}/sites").json()
for site in sites:
# Counted trees and survival, derived from approved visits only.
planting = client.get(f"/visits/planting/{site['id']}").json()
print(
project["name"],
site["name"],
f"pledged={site['expected_trees']}",
f"counted={planting['planted']}",
# None when the site has never been assessed — not zero. The
# distinction matters: absence of evidence is not evidence of loss.
f"survival={planting['rate']}",
f"observed={planting['observed_at']}",
)Note what this reads: figures derived from approved visits only. An unreviewed count is a claim, and the platform does not treat it as evidence — neither should your report.
Webhooks
Subscribe to events instead of polling. Every delivery is signed, and every delivery may arrive more than once.
Registering an endpoint
The signing secret is returned once, at registration. It begins whsec_.
curl -X POST "https://app.ecoattest.com/api/v1/webhooks" \
-H "X-API-Key: gp_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/webhooks/ecoattest",
"events": ["VisitApproved", "ImageUploaded"]
}'
# The signing secret is returned once, at registration, and never again.
# → { "id": "...", "url": "...", "events": [...], "secret": "whsec_..." }Events you can subscribe to
| Event | Fires when | Note |
|---|---|---|
| VisitSubmitted | A volunteer submits a site visit for review. | A claim, not yet evidence — do not report figures from this. |
| VisitApproved | A reviewer approves a visit. | The one that matters. Counts and survival become official here. |
| VisitRejected | A reviewer rejects a visit. | Carries the reason, so a coordinator can act on it. |
| ImageUploaded | Field evidence finishes uploading and is processed. | Fires after processing, so the image is retrievable when you receive it. |
| UserInvited | Somebody is invited to the tenant. | For mirroring access into your own directory or audit trail. |
Payload
Every payload carries __event__, event_id, occurred_at and tenant_id, plus the fields specific to that event.
{
"__event__": "VisitApproved",
"event_id": "6f1d0d6e-6f3f-4a5e-9a3e-2b0f4a9a1c77",
"occurred_at": "2026-09-05T06:14:22.481Z",
"tenant_id": "f5b870ca-1b41-428d-95b5-3295a9582e39",
"visit_id": "0b2b8b7a-1f2e-4a1b-9c3d-77a1f0e4b2c9",
"site_id": "678e367b-89a4-404d-abbd-a65c79bb4915",
"reviewer_id": "c1a2b3d4-5e6f-4708-9a0b-1c2d3e4f5061"
}Verifying the signature
Each delivery carries X-Green-Signature: the HMAC-SHA256 of the request body, keyed with your secret, hex-encoded. Verify against the raw bytes. Parsing the JSON and re-serialising it will reorder or respace keys, and the signature will not match — this is the single most common reason a first integration fails.
import hashlib
import hmac
import os
SECRET = os.environ["ECOATTEST_WEBHOOK_SECRET"] # the whsec_... value
def verify(raw_body: bytes, signature_header: str) -> bool:
"""Verify X-Green-Signature over the *raw* request body.
Sign the bytes exactly as received. Parsing the JSON and re-serialising it will
reorder or respace keys and the signature will not match.
"""
expected = hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
# Constant-time: a plain == leaks how much of the signature was correct.
return hmac.compare_digest(expected, signature_header)
# Flask
@app.post("/webhooks/ecoattest")
def receive():
if not verify(request.get_data(), request.headers.get("X-Green-Signature", "")):
return "", 401
event = request.get_json()
# Deduplicate on event_id. Delivery is retried until you return 2xx, so the
# same event can arrive more than once — your handler must be idempotent.
if already_processed(event["event_id"]):
return "", 200
handle(event)
return "", 200Delivery, retries and idempotency
A delivery is successful on any 2xx; anything else is retried. An event already delivered successfully is never sent again, but a delivery that timed out on your side after you processed it will be — so deduplicate on event_id and make your handler safe to run twice.
Return quickly. Do the work asynchronously and answer 200 as soon as you have the event stored, rather than holding the connection open while you process it.
OpenAPI specification
The machine-readable contract, generated from the running API rather than maintained alongside it.
The full specification is available from your workspace at https://app.ecoattest.com/api/v1/openapi.json, with browsable documentation at https://app.ecoattest.com/docs. Because the product’s own client is generated from it, it cannot drift from what the API actually does.
Point your generator at it to produce a typed client in your language rather than hand-writing request code.