Examples
End-to-end REST recipes - triage an inbox, draft and send a reply, pull an attachment, take events by webhook
Tools documents each endpoint on its own. This page strings them together into the flows people actually build: rank what needs attention, answer one thread, pull a file off a message, and stop polling once volume justifies webhooks.
Every example assumes these two variables:
export DAYSURFACE_API="https://api.daysurface.com"
export DAYSURFACE_API_KEY="sk_live_..."Running the server yourself? Point DAYSURFACE_API at http://localhost:8000.
Triage, read, reply
Four calls: rank the inbox, read the thread that came out on top, draft an answer, send it.
Rank what needs attention
curl -sS -X POST "$DAYSURFACE_API/api/v1/services/gmail_curate_inbox" \
-H "Authorization: Bearer $DAYSURFACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"limit": 10}'{
"threads": [
{
"thread_id": "18f0c1d2e3f4a5b6",
"subject": "Contract - can we meet this week?",
"from": "Sarah Chen <[email protected]>",
"snippet": "Following up on the redlines - are you free Thursday?",
"last_message_at": "2025-01-15T09:12:00+00:00",
"importance_score": 0.91,
"reasons": ["Direct question to you", "Awaiting your reply for 2 days"],
"labels": [{ "name": "Unread", "bg_color": "#f1f3f4", "text_color": "#444444" }]
}
]
}The score is deterministic - the same inbox state ranks the same way twice - so
it is safe to branch on in a script. reasons is the human-readable version of
why a thread scored where it did.
Read the thread
curl -sS -X POST "$DAYSURFACE_API/api/v1/services/gmail_get_thread" \
-H "Authorization: Bearer $DAYSURFACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"thread_id": "18f0c1d2e3f4a5b6", "strip_quoted_replies": true}'strip_quoted_replies drops the quoted history from each reply. On a long
thread that is most of the payload, and none of it is new information.
Attachments come back as metadata only - filename, mime_type, size_bytes,
attachment_id - so a 12 MB PDF costs you a few hundred bytes here. Fetch the
bytes when you need them; see Pull one attachment.
Draft the reply
gmail_compose is mutating, so it requires an Idempotency-Key. Generate one
per logical draft and reuse it across retries of that same draft.
curl -sS -X POST "$DAYSURFACE_API/api/v1/services/gmail_compose" \
-H "Authorization: Bearer $DAYSURFACE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Re: Contract - can we meet this week?",
"body": "Thursday at 2pm works. I will send an agenda tomorrow.",
"in_reply_to_thread_id": "18f0c1d2e3f4a5b6"
}'Passing in_reply_to_thread_id keeps the draft attached to the existing thread
rather than starting a new one. The response carries the saved draft -
draft_id, thread_id, recipients, subject, a body preview, and the
attachment list.
Send it
curl -sS -X POST "$DAYSURFACE_API/api/v1/services/gmail_send" \
-H "Authorization: Bearer $DAYSURFACE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"draft_id": "r-4820193847562"}'{
"message_id": "18f0c1d2e3f4a5c1",
"thread_id": "18f0c1d2e3f4a5b6",
"sent_at": "2025-01-15T09:41:22+00:00"
}There is no compose-and-send endpoint. Sending always references a draft that already exists, so the exact bytes going out were reviewable first - by a human, or by another step in your pipeline.
A morning digest, in Python
Ranks the inbox and prints the threads worth opening. Stops at the first thread below the cutoff, since the list is already ordered.
import os
import httpx
API = os.environ["DAYSURFACE_API"]
KEY = os.environ["DAYSURFACE_API_KEY"]
CUTOFF = 0.6
with httpx.Client(base_url=API, headers={"Authorization": f"Bearer {KEY}"}) as client:
resp = client.post(
"/api/v1/services/gmail_curate_inbox",
json={"query": "is:unread newer_than:1d", "limit": 25},
timeout=30.0,
)
resp.raise_for_status()
for thread in resp.json()["threads"]:
if thread["importance_score"] < CUTOFF:
break
print(f"{thread['importance_score']:.2f} {thread['from']}")
print(f" {thread['subject']}")
print(f" {', '.join(thread['reasons'])}\n")query takes Gmail's own search syntax, so the filtering you already do by hand
in the Gmail search bar transfers over unchanged.
Pull one attachment
gmail_get_thread gives you the message_id and attachment_id; this fetches
the bytes for exactly one of them.
curl -sS -X POST "$DAYSURFACE_API/api/v1/services/gmail_get_attachment" \
-H "Authorization: Bearer $DAYSURFACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"message_id": "18f0c1d2e3f4a5b6", "attachment_id": "ANGjdJ_x..."}' \
| jq -r '.data_base64' | base64 -d > contract.pdfdata_base64 is standard padded base64, ready to decode or re-upload as-is.
Attachments above the server's configured ceiling are refused rather than
returned - a deliberate guard against a single file blowing out an agent's
context window.
Filling and signing PDFs does not need this endpoint. The pdf_* tools work
from the attachment in place - see PDF Forms.
Take events instead of polling
Past a certain volume, re-ranking the inbox on a timer is the wrong shape. Give DaySurface an HTTPS endpoint and it will POST to you when mail arrives.
curl -sS -X POST "$DAYSURFACE_API/api/v1/services/webhook_subscribe" \
-H "Authorization: Bearer $DAYSURFACE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/daysurface", "event_types": ["gmail.message.new"]}'{
"id": "whsub_9f2a...",
"url": "https://example.com/hooks/daysurface",
"event_types": ["gmail.message.new"],
"active": true,
"secret": "whsec_..."
}secret is shown once and never again - store it on receipt. Every delivery is
signed with it under X-Webhook-Signature, and an endpoint that does not verify
the signature will accept anything anyone POSTs at it. Verification code, the
event payload shape, and the retry schedule are in
Gmail Webhooks. webhook_rotate_secret issues a
replacement; webhook_list shows what you have subscribed without ever
returning secrets.
Retrying without sending twice
Three failure modes to separate. A 429 or 5xx means the request may not have
run - retry it. A 4xx other than 429 means it ran and was rejected -
retrying unchanged will fail identically. A 402 is the one that looks
retryable and is not: it carries a Retry-After, but that value points at the
next daily quota reset, which can be hours out.
import time
import httpx
def call(client: httpx.Client, tool: str, payload: dict, key: str | None = None) -> dict:
headers = {"Idempotency-Key": key} if key else {}
for attempt in range(5):
resp = client.post(f"/api/v1/services/{tool}", json=payload, headers=headers)
# Daily quota. Sleeping on this Retry-After parks the loop until the
# next reset, so treat it as a stop condition rather than a retry.
if resp.status_code == 402:
reset = resp.json()["error"]["details"].get("quota_reset_at")
raise RuntimeError(f"{tool}: daily quota exhausted, resets at {reset}")
if resp.status_code == 429:
time.sleep(float(resp.headers.get("Retry-After", "1")))
continue
if resp.status_code >= 500:
time.sleep(2**attempt)
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError(f"{tool} did not succeed after 5 attempts")Hold the Idempotency-Key constant across the retries of one operation. The
first attempt that reaches the server executes; every later attempt with that
key replays the stored response instead of sending a second email. Reusing the
key with a different payload is a 422 - that is the guard working, not a
bug. See Idempotency,
Rate Limits, and the
reference retry loop, which adds
jitter and covers every retryable code.
Errors all share one envelope with a stable error.code and a request_id;
branch on the code, quote the request ID in support threads, and never match on
the message text. Full table in Errors.