A cross-platform social-media posting API built with .NET 10, Aspire, and FastEndpoints. Publish to Bluesky, Mastodon, and LinkedIn from a single request, with automatic threading for long posts and automated blog promotion from an RSS feed.
- Project Structure
- Getting Started
- Authentication
- API Endpoints
- POST /api/social-posts — Create Social Post (JSON)
- POST /api/social-posts/upload — Create Social Post (Multipart Upload)
- Scheduled Post Management
- POST /api/social-posts/scheduled/process — Process Due Scheduled Posts
- POST /api/social-posts/rss-promotion — Trigger RSS Blog Promotion
- POST /api/social-posts/rss-random — Post Random RSS Entry
- POST /api/social-posts/tips — Add Tip of the Day
- POST /api/social-posts/tips/post — Post Tip of the Day
- POST /api/social-posts/nasa-apod — Post NASA APOD to Social Platforms
- POST /api/social-posts/satellite — Post Satellite Image
- Job Scheduler
- GET /api/jobs — List Scheduled Jobs
- GET /api/jobs/{name} — Get a Scheduled Job
- POST /api/jobs — Create a Scheduled Job
- PUT /api/jobs/{name} — Update a Scheduled Job
- DELETE /api/jobs/{name} — Delete a Scheduled Job
- POST /api/jobs/{name}/run — Run a Job Immediately
- GET /api/jobs/{name}/runs — List a Job's Run History
- GET /api/jobs/types — List Registered Job Types
- GET /api/linkedin/auth — Initiate LinkedIn OAuth Flow
- GET /api/linkedin/auth/callback — LinkedIn OAuth Callback
- GET /api/linkedin/profile — Get LinkedIn Profile
- POST /api/word-cloud — Generate Word Cloud
- POST /api/signboard — Generate Signboard Image
- GET /api/avatars/random — Generate Random Avatar
- GET /api/github/auth — Initiate GitHub OAuth Flow
- GET /api/github/auth/callback — GitHub OAuth Callback
- GET /api/github/profile — Get GitHub Profile
- POST /api/github/repos/sync — Sync GitHub Repositories
- GET /api/github/repos — List GitHub Repositories
- GET /api/github/repos/{name} — Get Repository Details
- POST /api/github/repos/{name}/issues — Create GitHub Issue
- Configuration
- Deployment
- Production Notes
src/
├── BarretApi.Api # FastEndpoints API host
├── BarretApi.AppHost # Aspire orchestration & configuration
├── BarretApi.Core # Domain models, interfaces, services
├── BarretApi.Infrastructure # Platform clients (Bluesky, Mastodon, LinkedIn)
└── BarretApi.ServiceDefaults # Shared Aspire service defaults
tests/
├── BarretApi.Api.UnitTests
├── BarretApi.Core.UnitTests
└── BarretApi.Integration.Tests
- .NET 10 SDK
- Docker Desktop (for Azurite table storage in development)
# Start with Aspire AppHost (recommended — provisions Azurite automatically)
dotnet run --project src/BarretApi.AppHost/BarretApi.AppHost.csproj
# Or run the API project directly (requires manual configuration)
dotnet run --project src/BarretApi.Api/BarretApi.Api.csprojdotnet build
dotnet testSwagger UI is available in development at the root URL when running the API.
All mutating endpoints require an API key passed via the X-Api-Key header. The key is configured in the Aspire AppHost as the Auth:ApiKey parameter.
Starting either OAuth flow (/api/linkedin/auth or /api/github/auth) requires X-Api-Key. Callback and profile endpoints remain anonymous. All other GitHub endpoints require the API key.
OAuth setup must start and finish in the same HTTPS browser session. Each authorization request sets a Secure, HttpOnly, SameSite=Lax cookie and a provider-specific, single-use state that expires after ten minutes. Missing, mismatched, expired, or replayed state is rejected with HTTP 400 before any token exchange. A new setup attempt for the same provider in the same browser replaces the previous cookie.
Pending OAuth state is held in memory: restarting the API requires restarting authorization. With multiple replicas, route setup and callback to the same instance using session affinity; a callback on another replica fails closed.
Creates a cross-platform social post. Images are supplied as URL references and downloaded server-side before publishing.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Field | Type | Required | Description |
|---|---|---|---|
text |
string |
Yes (if no images) | Post body text (max 10,000 chars). |
hashtags |
string[] |
No | Hashtags to append (no spaces, max 100 chars each). |
platforms |
string[] |
No | Target platforms: bluesky, mastodon, linkedin. |
scheduledFor |
string (ISO 8601) |
No | Future UTC datetime for deferred posting. When set, request is queued and not published immediately. |
autoThread |
boolean |
No | When true, text exceeding the platform character limit is automatically split into a reply-chain thread. Defaults to false. |
dryRun |
boolean |
No | Preview prepared text without publishing or scheduling. With autoThread, returns the same text segments used by publishing. Defaults to false. |
images |
object[] |
No | Up to 4 image references. |
images[].url |
string |
Yes | Absolute URL of the image. |
images[].altText |
string |
Yes | Alt text for the image (max 1,500 chars). |
POST /api/social-posts{
"text": "Hello from BarretApi! #dotnet #aspire",
"hashtags": ["webapi"],
"platforms": ["linkedin", "bluesky", "mastodon"],
"images": [
{
"url": "https://example.com/photo.jpg",
"altText": "A descriptive alt text for the image"
}
]
}POST /api/social-posts{
"text": "Just shipped a new feature!",
"platforms": ["bluesky"]
}POST /api/social-posts{
"text": "Exploring the new .NET 10 features today",
"hashtags": ["dotnet", "csharp", "aspire"],
"platforms": ["bluesky", "mastodon"]
}POST /api/social-posts{
"text": "This is a very long post that exceeds the platform character limit. When autoThread is enabled, the text is automatically split into multiple segments and posted as a reply chain. Each segment breaks at paragraph or word boundaries for readability.",
"platforms": ["bluesky", "mastodon"],
"autoThread": true
}Set dryRun: true on the JSON endpoint to preview shortened text or thread segments. This takes precedence over scheduledFor. The response has dryRun: true, postedAt: null, and scheduled: false. Thread previews include threadedPosts with prepared text but no published IDs or URLs. Preview retrieves platform limits; it does not download, upload, or validate image bytes.
{
"text": "Your announcement text...",
"platforms": ["bluesky", "mastodon"],
"autoThread": true,
"dryRun": true
}POST /api/social-posts{
"text": "Launching the release announcement tomorrow morning.",
"hashtags": ["release", "dotnet"],
"platforms": ["linkedin", "bluesky"],
"scheduledFor": "2026-03-23T14:30:00Z"
}{
"results": [],
"postedAt": null,
"scheduled": true,
"scheduledPostId": "sp_01HZYD3M5Q9K6Q",
"scheduledFor": "2026-03-23T14:30:00+00:00"
}When autoThread is true and the text exceeds the platform limit, the response includes per-segment details:
{
"results": [
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/handle/post/xyz789",
"shortenedText": "First segment content",
"threaded": true,
"threadedPosts": [
{
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/handle/post/xyz789",
"publishedText": "First segment"
},
{
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/abc456",
"postUrl": "https://bsky.app/profile/handle/post/abc456",
"publishedText": "Second segment"
}
]
}
],
"postedAt": "2026-03-25T12:00:00+00:00"
}{
"results": [
{
"platform": "linkedin",
"success": true,
"postId": "urn:li:share:123456789",
"postUrl": "https://www.linkedin.com/feed/update/urn%3Ali%3Ashare%3A123456789",
"shortenedText": "Hello from BarretApi! #dotnet #aspire #webapi"
},
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/your-handle.bsky.social/post/xyz789",
"shortenedText": "Hello from BarretApi! #dotnet #aspire #webapi"
},
{
"platform": "mastodon",
"success": true,
"postId": "109876543210",
"postUrl": "https://mastodon.social/@you/109876543210",
"shortenedText": "Hello from BarretApi! #dotnet #aspire #webapi"
}
],
"postedAt": "2026-03-01T12:00:00+00:00"
}Returned when at least one platform succeeded and at least one failed.
{
"results": [
{
"platform": "linkedin",
"success": false,
"error": "LinkedIn API rejected the content",
"errorCode": "VALIDATION_FAILED"
},
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/your-handle.bsky.social/post/xyz789",
"shortenedText": "Hello from BarretApi! #dotnet #aspire #webapi"
}
],
"postedAt": "2026-03-01T12:00:00+00:00"
}{
"results": [
{
"platform": "linkedin",
"success": false,
"error": "Authentication failed",
"errorCode": "AUTH_FAILED"
},
{
"platform": "bluesky",
"success": false,
"error": "Rate limit exceeded",
"errorCode": "RATE_LIMITED"
}
],
"postedAt": "2026-03-01T12:00:00+00:00"
}| Code | Meaning |
|---|---|
| 200 | All targeted platforms succeeded. |
| 207 | Partial success — at least one platform succeeded and at least one failed. |
| 400 | Request validation failed. |
| 401 | Missing or invalid X-Api-Key. |
| 502 | All targeted platforms failed. |
When autoThread is true, text that exceeds a platform's character limit is split into multiple segments and posted as a reply chain. Text is split intelligently using grapheme cluster counting (correct for multi-byte Unicode) with the following break priority:
- Paragraph boundaries (
\n\n) - Line breaks (
\n) - Word boundaries
- Hard cut (last resort)
Images are attached only to the first segment of a thread. If any segment fails to post, subsequent segments are marked with error code THREAD_BROKEN.
| Platform | Thread Support | Character Limit | Chaining Mechanism |
|---|---|---|---|
| Bluesky | Yes | 300 grapheme clusters | Reply chain with root + parent references |
| Mastodon | Yes | Instance-dependent (typically 500) | Sequential replies via in_reply_to_id |
| No native threading | 3,000 characters | Each segment posted independently |
Creates a cross-platform social post with images uploaded as files via multipart/form-data. Alt texts are paired with images in order.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | multipart/form-data |
| Field | Type | Required | Description |
|---|---|---|---|
text |
string |
Yes (if no images) | Post body text (max 10,000 chars). |
hashtags |
string[] |
No | Hashtags to append. |
platforms |
string[] |
No | Target platforms: bluesky, mastodon, linkedin. |
scheduledFor |
string (ISO 8601) |
No | Future UTC datetime for deferred posting. |
images |
file[] |
No | Up to 4 image files (JPEG, PNG, GIF, WebP; max 1 MB each). |
altTexts |
string[] |
Yes (if images) | One alt text per image, matched by position (max 1,500 chars each). |
POST /api/social-posts/upload
Content-Type: multipart/form-data| Field | Value |
|---|---|
text |
Check out this screenshot! |
hashtags |
dotnet, aspire |
platforms |
bluesky, mastodon |
images |
screenshot.png |
altTexts |
Screenshot of the new dashboard |
POST /api/social-posts/upload
Content-Type: multipart/form-data| Field | Value |
|---|---|
text |
Before and after comparison |
platforms |
bluesky, linkedin |
images |
before.jpg, after.jpg |
altTexts |
Before the refactor, After the refactor |
POST /api/social-posts/upload
Content-Type: multipart/form-data| Field | Value |
|---|---|
text |
Scheduled image post for tomorrow |
platforms |
bluesky, mastodon |
scheduledFor |
2026-03-23T16:00:00Z |
images |
launch-banner.png |
altTexts |
Launch banner showing feature highlights |
Same response shape and status codes as POST /api/social-posts.
When scheduledFor is provided and is in the future, the response indicates the post was scheduled:
{
"results": [],
"postedAt": null,
"scheduled": true,
"scheduledPostId": "sp_01HZYD3M5Q9K6Q",
"scheduledFor": "2026-03-23T20:00:00+00:00"
}- Max 4 images per request.
- Allowed content types:
image/jpeg,image/png,image/gif,image/webp. - Max 1 MB per image.
- Alt text count must match image count.
- Alt texts must not be blank and must not exceed 1,500 characters.
Manage the existing one-off scheduled-post queue and its published history. These endpoints require X-Api-Key; mutations accept JSON. They do not call social providers. Immediate posts created without scheduledFor are not stored in this history. Recurring job definitions remain under /api/jobs.
| Method | Route | Purpose |
|---|---|---|
| GET | /api/social-posts/scheduled |
List scheduled records, including published, failed, and cancelled records. |
| GET | /api/social-posts/scheduled/{id} |
Inspect content, delivery receipts, operator confirmations, and the current version. |
| PATCH | /api/social-posts/scheduled/{id} |
Edit or reschedule a pending post that has never been attempted. |
| POST | /api/social-posts/scheduled/{id}/cancel |
Cancel a pending post while preserving its history. |
| POST | /api/social-posts/scheduled/{id}/reconcile |
Record externally verified, fully published deliveries. |
| POST | /api/social-posts/scheduled/{id}/retry |
Queue an explicit retry of the remaining verified non-deliveries. |
List query parameters:
| Parameter | Default | Description |
|---|---|---|
status |
All | Case-insensitive name: Pending, Processing, Published, Failed, NeedsReview, or Cancelled. |
from |
None | Inclusive lower bound on the scheduled timestamp, using an ISO 8601 date/time. Prefer an explicit UTC Z. |
to |
None | Inclusive upper bound on the scheduled timestamp; must not precede from. |
pageSize |
50 | Maximum page size, from 1 to 100. |
continuationToken |
None | Opaque token from the previous response. URL-encode it and keep the same filters. |
GET /api/social-posts/scheduled?status=NeedsReview&pageSize=50
X-Api-Key: YOUR_API_KEYThe response contains posts and continuationToken. Each summary includes scheduledPostId, status, version, textPreview, scheduledForUtc, createdAtUtc, publishedAtUtc, platforms, attemptCount, and successfulPlatformCount. Pagination follows Azure Table row-key order, not chronological order. Keep paging until the token is null, even if a page is empty. For a calendar, query the desired scheduled date range and sort the collected records by scheduledForUtc.
The details endpoint returns the summary under post, plus text, hashtags, autoThread, images, deliveries, confirmations, attempt/lease/update timestamps, error details, lastManagementAction, and managementNote. Image details identify URL versus uploaded sources, alt text, and available file metadata; they do not expose private blob names. Delivery details include platform post IDs, URLs, success/error information, and original thread-segment receipts. No raw exception objects are returned.
Every mutation requires version, copied exactly from the latest details response's post.version or list summary. Treat it as an opaque string; preserve embedded quotes when serializing JSON. Wildcard versions are rejected. A concurrent scheduler claim or another edit causes HTTP 409; reload the record before deciding whether to repeat the action. The URL identifies the post; an id supplied in the body cannot change the target.
| Status | Meaning |
|---|---|
| 200 | Operation completed; the response contains updated details and the new version. |
| 400 | Invalid input, missing version/confirmation, or invalid merged content. |
| 401 | Missing or invalid API key. |
| 404 | Scheduled post not found. |
| 409 | Stale version, concurrent write, or operation not allowed in the current state. |
PATCH fields are optional, but at least one change is required. Omitted or null fields retain their current value.
| Field | Description |
|---|---|
version |
Required current version. |
text |
Up to 10,000 characters. Empty text is allowed only if an image remains. |
hashtags |
Replacement array; [] clears it. Up to 100 nonblank tags, each at most 100 characters without whitespace. |
platforms |
Replacement array of distinct configured platform names; at least one is required when supplied. |
images |
Replacement URL attachments, each with url and altText. [] removes URL attachments. |
removeUploadedImages |
When true, removes uploaded attachments from the post. It does not delete the underlying blobs. |
autoThread |
Replaces the threading setting. |
scheduledFor |
New future ISO 8601 timestamp, normalized to UTC. |
The combined number of retained uploads and URL attachments cannot exceed four. URL attachments use HTTP(S) URLs up to 2,048 characters and nonblank alt text up to 1,500 characters. New file uploads still use the existing create-upload endpoint; PATCH does not accept arbitrary storage blob references.
PATCH /api/social-posts/scheduled/POST_ID
X-Api-Key: YOUR_API_KEY
Content-Type: application/json{
"version": "<current post.version>",
"text": "Updated launch announcement",
"autoThread": true,
"scheduledFor": "2026-09-20T14:00:00Z"
}Only Pending posts with no attempts or delivery history can be edited. A pending retry can be cancelled, but its content cannot be rewritten after earlier deliveries. To cancel, POST to /{id}/cancel with version and a nonblank note of at most 1,000 characters. Cancellation retains the record and receipts, prevents future processing, and does not remove anything already published. Processing, published, and cancelled records cannot be cancelled.
For a NeedsReview or Failed record:
- GET its details and inspect the provider accounts, including any partial threads.
- POST confirmed complete deliveries to
/{id}/reconcile. Each entry identifies an unresolved target platform and its published post ID; an HTTP(S) post URL is optional. - If unresolved platforms remain, the record stays
NeedsReviewand is not automatically replayed. Confirmations retain the operator's note and timestamp. Once all targets are confirmed successful, the record becomesPublished. - After verifying that no post exists for every remaining platform, POST to
/{id}/retrywith the new version, exactly those remaining platform names,confirmNotPublished: true, and an explanatory note.
Example reconciliation body:
{
"version": "<current post.version>",
"note": "Verified the complete Bluesky thread in the account",
"publishedDeliveries": [
{
"platform": "bluesky",
"postId": "at://did:plc:example/app.bsky.feed.post/example",
"postUrl": "https://bsky.app/profile/example/post/example"
}
]
}Example retry body after reloading the updated version:
{
"version": "<new post.version>",
"platforms": ["mastodon"],
"confirmNotPublished": true,
"note": "Checked Mastodon; neither a root post nor thread replies were published"
}Retry queues the record as Pending for the next processor run, or for an optional future scheduledFor. It retains successful receipts, which the processor skips. The endpoint does not publish synchronously. Repeated requests with an old version return 409.
A failed delivery with any published post ID, URL, or successful thread segment cannot be retried as a whole. Complete that thread manually and reconcile it as fully published. Original segment receipts remain available alongside the operator confirmation. Existing successful receipts cannot be overwritten.
Processing records must first be recovered by the scheduled-post processor after their claim expires; management endpoints never take over an active claim. For legacy records whose target list was omitted, reconciliation/retry resolves the current configured platform set and requires the caller to account for all of it. Operator notes and the latest management action are stored with the post; these fields are not a full immutable audit log. These controls cannot guarantee exactly-once delivery across provider APIs and local storage.
Processes scheduled posts that are due (scheduledFor <= now), posts them to configured target platforms, and returns run metrics.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
Scheduled records retain autoThread and the target platforms selected when scheduled. Each platform result is saved before the next platform is attempted. Subsequent runs skip successful deliveries and retry confirmed failures, such as rate limits or image preparation failures. A failure in one record or its notification does not stop the remaining batch.
Processing uses optimistic concurrency and a 15-minute claim, renewed before each platform attempt. Each attempt has a five-minute cancellation timeout. The next processing run moves expired claims to NeedsReview, returning DELIVERY_UNCERTAIN in the run's failures. Partial threads and ambiguous provider failures also require review: a timeout or interrupted process may have occurred after a post was accepted. Automatic HTTP retries for publishing methods are disabled.
Use the scheduled post management endpoints to inspect receipts, confirm remotely published deliveries, and explicitly retry verified non-deliveries. Complete partially published threads manually and reconcile them instead of replaying the whole thread. There is no need to edit storage records directly.
Legacy pending records remain supported (autoThread defaults to false because it was not previously stored). Legacy failed records without delivery tracking require review before retrying; their earlier successful deliveries cannot be inferred safely. These safeguards do not promise exactly-once delivery across a remote provider and local storage.
| Field | Type | Required | Description |
|---|---|---|---|
maxCount |
int |
No | Optional cap for number of due posts to process in a single run (1-1000). |
POST /api/social-posts/scheduled/process{
"maxCount": 100
}{
"runId": "sched-run-20260322180000-a1b2c3",
"startedAtUtc": "2026-03-22T18:00:00+00:00",
"completedAtUtc": "2026-03-22T18:00:03+00:00",
"dueCount": 3,
"attemptedCount": 3,
"succeededCount": 2,
"failedCount": 1,
"skippedCount": 0,
"failures": [
{
"scheduledPostId": "sp_01HZYD3M5Q9K6Q",
"scheduledForUtc": "2026-03-22T17:59:00+00:00",
"platforms": ["bluesky", "mastodon"],
"errorCode": "PLATFORM_ERROR",
"errorMessage": "No platform succeeded for the scheduled post.",
"attemptedAtUtc": "2026-03-22T18:00:02+00:00"
}
]
}Returned when at least one due post was attempted and no attempts succeeded.
| Code | Meaning |
|---|---|
| 200 | Processing completed with at least one success, or no due posts to process. |
| 400 | Request validation failed. |
| 401 | Missing or invalid X-Api-Key. |
| 502 | Due posts were attempted and all attempts failed. |
Reads the configured RSS feed, posts newly published entries first, then posts any eligible reminder entries. Tracks which entries have been posted using Azure Table Storage to avoid duplicates. Initial posts contain the entry title and URL. Reminder posts are prefixed with "In case you missed it earlier..." followed by a blank line before the entry title and URL.
Supports both standard Atom 2.0 / RSS 2.0 feeds and the custom blog feed format. All entries are eligible regardless of whether they have tags — the same standard feed fallbacks described in the rss-random endpoint apply here (summary, hero image, and tag extraction from standard feed elements).
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json (optional — body may be omitted or empty) |
All fields are optional. When the body is omitted entirely, the endpoint uses the configured defaults.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
feedUrl |
string |
No | Server config | URL of the RSS/Atom feed to read. Must be an absolute http or https URL. Falls back to the configured default when omitted or empty. |
header |
string |
No | (none) | Text prepended to every post (initial and reminder). Separated from the post body by a blank line. |
recentDaysWindow |
int |
No | Server config | Number of days back to look for posts to promote. Must be greater than 0 when provided. Overrides the configured RecentDaysWindow. |
POST /api/social-posts/rss-promotionPOST /api/social-posts/rss-promotion
Content-Type: application/json
{
"feedUrl": "https://example.com/custom-feed.xml",
"header": "Check out this blog post!",
"recentDaysWindow": 14
}{
"runId": "promo-a1b2c3d4",
"startedAtUtc": "2026-03-04T10:00:00+00:00",
"completedAtUtc": "2026-03-04T10:00:05+00:00",
"entriesEvaluated": 12,
"newPostsAttempted": 2,
"newPostsSucceeded": 2,
"reminderPostsAttempted": 1,
"reminderPostsSucceeded": 1,
"entriesSkippedAlreadyPosted": 8,
"entriesSkippedOutsideWindow": 1,
"failures": [],
"lastTwoBlogPosts": [
{
"entryIdentity": "https://example.com/blog/post-1",
"canonicalUrl": "https://example.com/blog/post-1",
"title": "My Latest Blog Post",
"publishedAtUtc": "2026-03-03T08:00:00+00:00"
},
{
"entryIdentity": "https://example.com/blog/post-2",
"canonicalUrl": "https://example.com/blog/post-2",
"title": "Another Great Post",
"publishedAtUtc": "2026-03-01T14:30:00+00:00"
}
]
}{
"runId": "failed-a1b2c3d4",
"startedAtUtc": "2026-03-04T10:00:00+00:00",
"completedAtUtc": "2026-03-04T10:00:00+00:00",
"entriesEvaluated": 0,
"newPostsAttempted": 0,
"newPostsSucceeded": 0,
"reminderPostsAttempted": 0,
"reminderPostsSucceeded": 0,
"entriesSkippedAlreadyPosted": 0,
"entriesSkippedOutsideWindow": 0,
"failures": [
{
"entryIdentity": "rss-feed",
"canonicalUrl": "",
"phase": "Initial",
"platform": "rss-promotion",
"errorCode": "UNHANDLED_EXCEPTION",
"errorMessage": "Unable to read RSS feed"
}
],
"lastTwoBlogPosts": []
}| Code | Meaning |
|---|---|
| 200 | Promotion run completed (may include partial failures in the failures array). |
| 400 | Invalid blog-promotion configuration. |
| 401 | Missing or invalid X-Api-Key. |
| 502 | Feed read failed or all posting attempts failed. |
Fetches an RSS feed, applies optional filters (tag exclusion, recency, platform targeting), randomly selects one eligible entry, and posts it to the targeted social platforms. The post text is prefixed with "From the archives…" and includes the entry title, URL, qualifying hashtags, and hero image if available. This endpoint is stateless — it does not track previously posted entries.
Supports both standard Atom 2.0 / RSS 2.0 feeds and the custom blog feed format with https://barretblake.dev/ns/ namespace extensions. When custom extensions are absent, the endpoint falls back to standard feed elements:
- Summary: Prefers
<summary>, falls back to Atom<content>. HTML is stripped to plain text automatically. - Hero image: Prefers custom
<hero>extension, falls back to enclosure links withimage/*media type, then Media RSS<media:thumbnail>/<media:content>. - Tags: Prefers custom
<tags>extension, falls back to standard<category>elements. All entries are eligible regardless of whether they have tags.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
feedUrl |
string |
Yes | — | Absolute URL of the RSS feed (http or https). |
platforms |
string[] |
No | All configured | Target platforms: bluesky, mastodon, linkedin. |
excludeTags |
string[] |
No | [] |
Tags to exclude (case-insensitive match against entry tags). |
maxAgeDays |
int |
No | No limit | Only include entries published within this many days. Must be > 0. |
header |
string |
No | — | Optional header text prepended between the leader line and the entry title. |
curl -s -X POST https://localhost:7042/api/social-posts/rss-random \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{
"feedUrl": "https://example.com/blog/feed.xml"
}'curl -s -X POST https://localhost:7042/api/social-posts/rss-random \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{
"feedUrl": "https://example.com/blog/feed.xml",
"platforms": ["bluesky", "mastodon"],
"excludeTags": ["personal", "draft"],
"maxAgeDays": 30,
"header": "Check this out!"
}'{
"selectedTitle": "Building APIs with .NET Aspire",
"selectedUrl": "https://example.com/blog/aspire-apis",
"results": [
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/handle.bsky.social/post/xyz789",
"shortenedText": "From the archives...\n\nBuilding APIs with .NET Aspire\nhttps://example.com/blog/aspire-apis #dotnet #aspire"
},
{
"platform": "mastodon",
"success": true,
"postId": "109876543210",
"postUrl": "https://mastodon.social/@you/109876543210",
"shortenedText": "From the archives...\n\nBuilding APIs with .NET Aspire\nhttps://example.com/blog/aspire-apis #dotnet #aspire"
}
],
"postedAt": "2026-03-04T12:00:00+00:00"
}{
"selectedTitle": "Building APIs with .NET Aspire",
"selectedUrl": "https://example.com/blog/aspire-apis",
"results": [
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/handle.bsky.social/post/xyz789",
"shortenedText": "From the archives...\n\nBuilding APIs with .NET Aspire\nhttps://example.com/blog/aspire-apis #dotnet #aspire"
},
{
"platform": "mastodon",
"success": false,
"error": "Authentication failed",
"errorCode": "AUTH_FAILED"
}
],
"postedAt": "2026-03-04T12:00:00+00:00"
}| Code | Meaning |
|---|---|
| 200 | All targeted platforms succeeded. |
| 207 | Partial success — at least one platform succeeded and at least one failed. |
| 400 | Request validation failed (missing feedUrl, invalid URL, invalid platform, maxAgeDays ≤ 0). |
| 401 | Missing or invalid X-Api-Key. |
| 422 | No eligible entries remain after filtering. |
| 502 | Feed could not be read, or all targeted platform posts failed. |
Adds one or more categorized tips to Azure Table Storage. New tips start with lastPostedDate = null, making them eligible for future tip-of-the-day posting.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Field | Type | Required | Description |
|---|---|---|---|
category |
string |
Yes | Tip category used for later random selection. |
tips |
object[] |
Yes | One or more tip objects. Maximum 100 per request. |
tips[].tip |
string |
Yes | Tip text to post. |
tips[].moreInfoUrl |
string |
No | Optional HTTP/HTTPS URL appended to the post. |
POST /api/social-posts/tips
X-Api-Key: <your-api-key>
Content-Type: application/json{
"category": "dotnet",
"tips": [
{
"tip": "Prefer file-scoped namespaces for new C# files.",
"moreInfoUrl": "https://learn.microsoft.com/dotnet/csharp/language-reference/keywords/namespace"
},
{
"tip": "Use primary constructors when they make dependencies obvious."
}
]
}| Code | Meaning |
|---|---|
| 201 | Tips were added. |
| 400 | Request validation failed. |
| 401 | Missing or invalid X-Api-Key. |
Randomly selects an eligible tip from Azure Table Storage for the requested category, posts it to selected social platforms, then updates lastPostedDate when at least one platform succeeds. Eligible tips have lastPostedDate = null or were last posted before the configured cooldown window, which defaults to 180 days.
The generated social text uses this format:
leader
tip
optional URL
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Platforms | bluesky, mastodon, linkedin; omit or empty for all configured platforms |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
category |
string |
Yes | — | Category to select tips from. |
platforms |
string[] |
No | All configured platforms | Social platforms to publish to. |
leader |
string |
No | — | Optional first line of the social post. |
POST /api/social-posts/tips/post
X-Api-Key: <your-api-key>
Content-Type: application/json{
"category": "dotnet",
"platforms": ["bluesky", "mastodon"],
"leader": "Tip of the day"
}| Code | Meaning |
|---|---|
| 200 | All targeted platforms succeeded. |
| 207 | Partial success — at least one platform succeeded and at least one failed. |
| 400 | Request validation failed. |
| 401 | Missing or invalid X-Api-Key. |
| 422 | No eligible tips found for the requested category. |
| 502 | All targeted platforms failed to post. |
Fetches the NASA Astronomy Picture of the Day and posts it to selected social media platforms. Supports image and video APODs, with automatic image resizing and copyright attribution.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Field | Type | Required | Description |
|---|---|---|---|
date |
string |
No | Date in YYYY-MM-DD format. Defaults to today. Must be between 1995-06-16 and today. |
platforms |
string[] |
No | Target platforms: bluesky, mastodon, linkedin. Defaults to all configured. |
POST /api/social-posts/nasa-apod{}POST /api/social-posts/nasa-apod{
"date": "2026-02-14",
"platforms": ["bluesky", "mastodon"]
}{
"title": "The Aurora Tree",
"date": "2026-03-08",
"mediaType": "image",
"imageUrl": "https://apod.nasa.gov/apod/image/2603/AuroraTree_Wallace_960.jpg",
"hdImageUrl": "https://apod.nasa.gov/apod/image/2603/AuroraTree_Wallace_2048.jpg",
"copyright": "Alyn Wallace",
"imageAttached": true,
"imageResized": false,
"results": [
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/user/post/xyz789"
},
{
"platform": "mastodon",
"success": true,
"postId": "109876543210",
"postUrl": "https://mastodon.social/@user/109876543210"
}
],
"postedAt": "2026-03-08T15:30:00Z"
}{
"title": "The Aurora Tree",
"date": "2026-03-08",
"mediaType": "image",
"imageUrl": "https://apod.nasa.gov/apod/image/2603/AuroraTree_Wallace_960.jpg",
"imageAttached": true,
"imageResized": false,
"results": [
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/user/post/xyz789"
},
{
"platform": "mastodon",
"success": false,
"error": "Rate limit exceeded",
"errorCode": "RATE_LIMITED"
}
],
"postedAt": "2026-03-08T15:30:00Z"
}{
"statusCode": 422,
"message": "Failed to fetch APOD from NASA API: Response status code does not indicate success: 429 (Too Many Requests)."
}- Image APOD: Downloads the image, uses the APOD
explanationfield as alt text, attaches the image to the post. The HD image URL is included in the post text. - Video APOD: Uses the video thumbnail as the post image if available; otherwise posts text-only with the video URL.
- Copyright: If the APOD has a copyright holder, a
Credit: {holder}line is appended to the post text. - Image Resizing: Images are automatically resized to fit each platform's limits using a quality-first strategy (JPEG quality 85→45), falling back to dimension reduction if needed.
| Code | Meaning |
|---|---|
| 200 | All targeted platforms succeeded. |
| 207 | Partial success — at least one platform succeeded and at least one failed. |
| 400 | Request validation failed (invalid date format, date out of range, invalid platform). |
| 401 | Missing or invalid X-Api-Key. |
| 422 | NASA API returned an error or the APOD could not be fetched. |
| 502 | All targeted platforms failed. |
Fetches a satellite image from NASA GIBS (Global Imagery Browse Services) and posts it to selected social media platforms. The image is captured from the Worldview Snapshot API using configurable satellite imagery layers and date.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Field | Type | Required | Description |
|---|---|---|---|
date |
string |
No | Date in YYYY-MM-DD format. Defaults to yesterday (UTC). Must not be before the selected layer's start date or in the future. |
layer |
string |
No | Satellite imagery layer. Defaults to MODIS_Terra_CorrectedReflectance_TrueColor. See supported layers below. |
platforms |
string[] |
No | Target platforms: bluesky, mastodon, linkedin. Defaults to all configured. |
title |
string |
No | Custom title for the post caption. Defaults to "Satellite view of Ohio" (configurable default). Max 200 characters. |
description |
string |
No | Custom alt text for the image. Defaults to auto-generated text with date and layer. Max 1000 characters. |
bboxSouth |
number |
No | Southern boundary latitude (-90 to 90). Defaults to 38.40. Must be less than bboxNorth. |
bboxWest |
number |
No | Western boundary longitude (-180 to 180). Defaults to -84.82. Must be less than bboxEast. |
bboxNorth |
number |
No | Northern boundary latitude (-90 to 90). Defaults to 42.32. Must be greater than bboxSouth. |
bboxEast |
number |
No | Eastern boundary longitude (-180 to 180). Defaults to -80.52. Must be greater than bboxWest. |
imageWidth |
integer |
No | Snapshot image width in pixels (1–8192). Defaults to 1024. |
imageHeight |
integer |
No | Snapshot image height in pixels (1–8192). Defaults to 768. |
| Layer | Instrument | Available From |
|---|---|---|
MODIS_Terra_CorrectedReflectance_TrueColor |
MODIS (Terra) | 2000-02-24 |
MODIS_Aqua_CorrectedReflectance_TrueColor |
MODIS (Aqua) | 2002-07-04 |
VIIRS_SNPP_CorrectedReflectance_TrueColor |
VIIRS (Suomi NPP) | 2015-11-24 |
VIIRS_NOAA20_CorrectedReflectance_TrueColor |
VIIRS (NOAA-20) | 2017-12-01 |
VIIRS_NOAA21_CorrectedReflectance_TrueColor |
VIIRS (NOAA-21) | 2024-01-17 |
POST /api/social-posts/satellite{}POST /api/social-posts/satellite{
"date": "2026-02-14",
"layer": "VIIRS_SNPP_CorrectedReflectance_TrueColor",
"platforms": ["bluesky", "mastodon"]
}POST /api/social-posts/satellite{
"title": "Satellite view of the Grand Canyon",
"description": "Aerial satellite image of the Grand Canyon, Arizona, captured by NASA GIBS.",
"bboxSouth": 35.9,
"bboxWest": -112.6,
"bboxNorth": 36.5,
"bboxEast": -111.6,
"imageWidth": 1280,
"imageHeight": 960,
"platforms": ["bluesky"]
}{
"date": "2026-03-15",
"layer": "MODIS_Terra_CorrectedReflectance_TrueColor",
"title": "Satellite view of Ohio",
"worldviewUrl": "https://worldview.earthdata.nasa.gov/?v=-84.82,38.40,-80.52,42.32&l=MODIS_Terra_CorrectedReflectance_TrueColor&t=2026-03-15",
"bboxSouth": 38.40,
"bboxWest": -84.82,
"bboxNorth": 42.32,
"bboxEast": -80.52,
"imageWidth": 1024,
"imageHeight": 768,
"imageAttached": true,
"imageResized": false,
"results": [
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/user/post/xyz789"
},
{
"platform": "mastodon",
"success": true,
"postId": "109876543210",
"postUrl": "https://mastodon.social/@user/109876543210"
}
],
"postedAt": "2026-03-15T15:30:00Z"
}{
"statusCode": 422,
"message": "Failed to fetch snapshot from NASA GIBS: GIBS returned an error: Missing or invalid TIME parameter"
}- Default Date: Uses yesterday's date (UTC) when no date is specified, since same-day imagery may not yet be available.
- Custom Region: Override
bboxSouth,bboxWest,bboxNorth, andbboxEastto capture any geographic region. Defaults to a preconfigured bounding box. - Custom Dimensions: Override
imageWidthandimageHeight(1–8192) to control the snapshot resolution. Defaults to 1024×768. - Image Attachment: The GIBS snapshot (JPEG, typically 80–400 KB at default resolution) is attached directly to the social post.
- Post Caption: Includes the title (customizable), date, layer name, a Worldview link for interactive exploration, and NASA GIBS acknowledgement.
- Alt Text: Uses
descriptionif provided; otherwise auto-generates alt text from the date and layer. - Hashtags: Posts include
#satellite,#NASA, and#EarthObservation. - Worldview Link: Each post includes a link to NASA Worldview showing the same view, allowing viewers to explore the imagery interactively.
- No API Key Required: NASA GIBS is publicly accessible — no NASA API key is needed.
All GIBS parameters have sensible defaults and are configured in the Aspire AppHost. See NASA GIBS in the Configuration section for the full reference.
| Config Key | Aspire Parameter | Environment Variable | Default | Description |
|---|---|---|---|---|
NasaGibs:BaseUrl |
gibs-base-url |
NasaGibs__BaseUrl |
https://wvs.earthdata.nasa.gov/api/v1/snapshot |
GIBS Worldview Snapshot API base URL. |
NasaGibs:DefaultLayer |
gibs-default-layer |
NasaGibs__DefaultLayer |
MODIS_Terra_CorrectedReflectance_TrueColor |
Default imagery layer. |
NasaGibs:BboxSouth |
gibs-bbox-south |
NasaGibs__BboxSouth |
38.40 |
Southern boundary (latitude). |
NasaGibs:BboxWest |
gibs-bbox-west |
NasaGibs__BboxWest |
-84.82 |
Western boundary (longitude). |
NasaGibs:BboxNorth |
gibs-bbox-north |
NasaGibs__BboxNorth |
42.32 |
Northern boundary (latitude). |
NasaGibs:BboxEast |
gibs-bbox-east |
NasaGibs__BboxEast |
-80.52 |
Eastern boundary (longitude). |
NasaGibs:ImageWidth |
gibs-image-width |
NasaGibs__ImageWidth |
1024 |
Snapshot image width in pixels. |
NasaGibs:ImageHeight |
gibs-image-height |
NasaGibs__ImageHeight |
768 |
Snapshot image height in pixels. |
| Code | Meaning |
|---|---|
| 200 | All targeted platforms succeeded. |
| 207 | Partial success — at least one platform succeeded and at least one failed. |
| 400 | Request validation failed (invalid date, unsupported layer, invalid platform, bbox out of range, image dimensions out of range, title/description too long). |
| 401 | Missing or invalid X-Api-Key. |
| 422 | NASA GIBS returned an error or the snapshot could not be fetched. |
| 502 | All targeted platforms failed. |
BarretApi can run its own recurring jobs instead of relying on Power Automate to call these endpoints on a timer. Job definitions live in Azure Table Storage and are fully managed over /api/jobs* — create, update, pause, delete, and trigger a run by hand, all without a redeploy. A background tick loop dispatches due jobs to handlers that wrap the same services the /api/social-posts/* endpoints above already call, retries failures with backoff, and keeps a durable run history.
JobScheduler:Enabled defaults to false, so a local dotnet run never fires real posts. The Power Automate flows that currently drive this scheduled work are being retired in favor of this scheduler; during the migration, both triggers call the same underlying services, so a flow and its equivalent job can run side by side, and either can be paused independently with no code change.
See docs/JOB_SCHEDULER.md for the full endpoint reference, job-type arguments, cron/time-zone rules, configuration table, and the migration guide.
Production prerequisite: the
barretapiAzure Web App must have Always On enabled, or App Service unloads the process when idle and the tick loop stops. See Job Scheduler Configuration in Production Notes.
Starts the LinkedIn OAuth authorization flow. Call with the API key from the same HTTPS browser session that will receive the callback. Requests accepting HTML redirect to the provider; JSON requests return an authorization URL.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
From a page on your API's HTTPS origin, run this in the browser developer console. The browser retains the correlation cookie while navigating to LinkedIn:
const response = await fetch("/api/linkedin/auth", {
headers: { "X-Api-Key": prompt("API key"), "Accept": "application/json" },
credentials: "same-origin"
});
if (!response.ok) throw new Error("OAuth setup failed");
const { authUrl } = await response.json();
window.location.assign(authUrl);GET /api/linkedin/auth
X-Api-Key: YOUR_API_KEY
Accept: application/jsonThe callback must include the cookie issued to this client. Copying only the authorization URL into a different browser will fail. Prefer the browser example above for interactive setup.
{
"authUrl": "https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fyour-api-host%2Fapi%2Flinkedin%2Fauth%2Fcallback&state=abc123&scope=openid%20profile%20w_member_social"
}Receives the authorization code from LinkedIn after the user approves access. Exchanges the code for access and refresh tokens and persists them to Azure Table Storage.
| Detail | Value |
|---|---|
| Auth | None (anonymous) |
This endpoint is called automatically by LinkedIn after authorization. You do not need to call it manually.
| Parameter | Description |
|---|---|
code |
Authorization code from LinkedIn. |
state |
Required, single-use value from setup; must match the initiating browser cookie and provider within ten minutes. |
error |
Error code if authorization was denied. |
error_description |
Human-readable error description. |
{
"success": true,
"message": "LinkedIn authorization successful. Tokens have been saved."
}{
"success": false,
"message": "LinkedIn authorization denied: user_cancelled_authorize"
}{
"success": false,
"message": "Token exchange failed: LinkedIn token exchange failed: invalid_grant"
}Returns your LinkedIn profile info, including the member URN (sub) needed for the LinkedIn:AuthorUrn configuration value.
| Detail | Value |
|---|---|
| Auth | None (anonymous) |
Note: You must complete the LinkedIn OAuth flow (
/api/linkedin/auth) before calling this endpoint.
GET /api/linkedin/profile{
"sub": "abc123def456",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"picture": "https://media.licdn.com/...",
"email": "john@example.com"
}Use the sub value to construct your AuthorUrn: urn:li:person:<sub>.
{
"error": "No LinkedIn tokens found. Visit /api/linkedin/auth first."
}Generates a PNG word cloud image from the visible text content of a web page. Common English stop words are excluded and words are sized proportionally to their frequency on the page.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Response Content-Type | image/png |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string |
Yes | — | Absolute HTTP or HTTPS URL of the target web page. |
width |
integer |
No | 800 |
Output image width in pixels (200–2000). |
height |
integer |
No | 600 |
Output image height in pixels (200–2000). |
POST /api/word-cloud{
"url": "https://en.wikipedia.org/wiki/.NET"
}{
"url": "https://en.wikipedia.org/wiki/.NET",
"width": 1200,
"height": 800
}Binary PNG image data with Content-Type: image/png.
curl -X POST http://localhost:5000/api/word-cloud \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{"url": "https://en.wikipedia.org/wiki/.NET"}' \
--output word-cloud.png{
"statusCode": 400,
"message": "One or more validation errors occurred.",
"errors": {
"url": ["The URL must be a valid absolute HTTP or HTTPS URL."]
}
}{
"statusCode": 422,
"message": "The page contains insufficient text content to generate a word cloud."
}{
"statusCode": 502,
"message": "Failed to fetch the web page. The target URL is unreachable or returned an error."
}| Aspect | Value |
|---|---|
| Fetch timeout | 30 seconds |
| Max HTML size | 500 KB |
| Max words in cloud | 100 |
| Min word length | 3 characters |
| Image size range | 200×200 to 2000×2000 px |
Renders text as a letterboard-style lightbox sign (white board, dark frame, tile letters with occasional red accents) and returns it as a PNG. When platforms is supplied, the image is instead posted to the targeted social platforms and a JSON result is returned.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Response Content-Type | image/png (generate-only) or application/json (posting mode) |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
text |
string |
Yes | — | Sign text (max 200 chars). Rendered uppercase. Newlines force line breaks; text auto-wraps otherwise. Supported characters: A–Z, 0–9, space, newline, and ! ? . , ' " & @ # $ % - + / : ;. |
width |
integer |
No | 1200 |
Image width in pixels (400–2000). |
height |
integer |
No | 900 |
Image height in pixels (400–2000). |
seed |
integer |
No | Random | Same text + seed + dimensions produce byte-identical output. The seed used is echoed in the X-Signboard-Seed response header (PNG mode) or the seed field (JSON mode). |
platforms |
string[] |
No | — | When present and non-empty, posts the image to bluesky, mastodon, and/or linkedin and returns JSON results instead of the PNG. |
caption |
string |
No | Sign text | Post body text when posting (max 1000 chars). |
hashtags |
string[] |
No | — | Hashtags appended when posting (no spaces, max 100 chars each). |
altText |
string |
No | Auto | Image alt text when posting (max 1500 chars). Defaults to A signboard that reads: {text} where {text} is the sign text as passed in, with newlines replaced by spaces. |
curl -X POST http://localhost:5000/api/signboard \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{"text": "I USED TO THINK\nI WAS INDECISIVE\nBUT NOW\nI'\''M NOT SURE", "seed": 42}' \
--output signboard.pngPOST /api/signboard{
"text": "SORRY WE ARE OPEN",
"platforms": ["bluesky", "mastodon"],
"caption": "New sign day!",
"hashtags": ["signboard"],
"altText": "A signboard that reads: sorry we are open"
}{
"width": 1200,
"height": 900,
"seed": 42,
"results": [
{
"platform": "bluesky",
"success": true,
"postId": "at://did:plc:abc123/app.bsky.feed.post/xyz789",
"postUrl": "https://bsky.app/profile/handle.bsky.social/post/xyz789"
},
{
"platform": "mastodon",
"success": true,
"postId": "109876543210",
"postUrl": "https://mastodon.social/@you/109876543210"
}
],
"postedAt": "2026-07-28T12:00:00+00:00"
}| Code | Meaning |
|---|---|
| 200 | PNG generated (generate-only) or all targeted platforms succeeded (posting mode). |
| 207 | Partial success — at least one platform succeeded and at least one failed. |
| 400 | Request validation failed (missing/overlong text, unsupported characters, invalid dimensions or platform). |
| 401 | Missing or invalid X-Api-Key. |
| 500 | Unexpected error during image generation. |
| 502 | All targeted platforms failed to post. |
Generates a random avatar image using the DiceBear API (v9.x). Returns the raw image bytes directly. Optionally specify a style, format, and seed for customization.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Method | GET |
| Response Content-Type | Varies by format (default image/svg+xml) |
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
style |
string |
No | Random | One of the 31 supported DiceBear styles (e.g., pixel-art, adventurer, bottts). |
format |
string |
No | svg |
Output format: svg, png, jpg, webp, or avif. |
seed |
string |
No | Random GUID | Seed for reproducible avatars. Max 256 characters. Same seed + style = same avatar. |
adventurer, adventurer-neutral, avataaars, avataaars-neutral, big-ears, big-ears-neutral, big-smile, bottts, bottts-neutral, croodles, croodles-neutral, dylan, fun-emoji, glass, icons, identicon, initials, lorelei, lorelei-neutral, micah, miniavs, notionists, notionists-neutral, open-peeps, personas, pixel-art, pixel-art-neutral, rings, shapes, thumbs, toon-head
curl http://localhost:5000/api/avatars/random \
-H "X-Api-Key: YOUR_API_KEY" \
--output avatar.svgcurl "http://localhost:5000/api/avatars/random?style=pixel-art&format=png" \
-H "X-Api-Key: YOUR_API_KEY" \
--output avatar.pngcurl "http://localhost:5000/api/avatars/random?seed=john-doe&style=bottts&format=webp" \
-H "X-Api-Key: YOUR_API_KEY" \
--output avatar.webpBinary image data with the appropriate Content-Type header:
| Format | Content-Type |
|---|---|
svg |
image/svg+xml |
png |
image/png |
jpg |
image/jpeg |
webp |
image/webp |
avif |
image/avif |
{
"statusCode": 400,
"message": "One or more validation errors occurred.",
"errors": {
"style": ["'Style' must be one of the supported styles: adventurer, adventurer-neutral, ..."]
}
}{
"statusCode": 502,
"message": "The avatar generation service is temporarily unavailable."
}| Code | Meaning |
|---|---|
| 200 | Avatar image generated successfully. |
| 400 | Invalid style, format, or seed (exceeds 256 chars). |
| 401 | Missing or invalid X-Api-Key. |
| 502 | DiceBear API is unreachable or returned an error. |
Starts the GitHub OAuth authorization flow. Call with the API key from the same HTTPS browser session that will receive the callback. Requests accepting HTML redirect to the provider; JSON requests return an authorization URL.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
From a page on your API's HTTPS origin, run this in the browser developer console. The browser retains the correlation cookie while navigating to GitHub:
const response = await fetch("/api/github/auth", {
headers: { "X-Api-Key": prompt("API key"), "Accept": "application/json" },
credentials: "same-origin"
});
if (!response.ok) throw new Error("OAuth setup failed");
const { authUrl } = await response.json();
window.location.assign(authUrl);GET /api/github/auth
X-Api-Key: YOUR_API_KEY
Accept: application/jsonThe callback must include the cookie issued to this client. Copying only the authorization URL into a different browser will fail. Prefer the browser example above for interactive setup.
{
"authUrl": "https://github.com/login/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fyour-api-host%2Fapi%2Fgithub%2Fauth%2Fcallback&scope=repo&state=abc123"
}Receives the authorization code from GitHub after the user approves access. Exchanges the code for an access token, retrieves the user profile, and persists the token to Azure Table Storage.
| Detail | Value |
|---|---|
| Auth | None (anonymous) |
This endpoint is called automatically by GitHub after authorization. You do not need to call it manually.
| Parameter | Description |
|---|---|
code |
Authorization code from GitHub. |
state |
Required, single-use value from setup; must match the initiating browser cookie and provider within ten minutes. |
error |
Error code if authorization was denied. |
error_description |
Human-readable error description. |
{
"username": "octocat",
"status": "connected",
"scope": "repo"
}{
"statusCode": 400,
"message": "GitHub authorization denied: access_denied"
}Returns the currently connected GitHub user info, or indicates no connection exists.
| Detail | Value |
|---|---|
| Auth | None (anonymous) |
GET /api/github/profile{
"username": "octocat",
"connected": true,
"scope": "repo",
"connectedAtUtc": "2026-03-25T14:00:00Z"
}{
"username": null,
"connected": false,
"scope": null,
"connectedAtUtc": null
}Fetches all repositories owned by the authenticated GitHub user and stores them locally, replacing any previously stored data.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
POST /api/github/repos/sync
X-Api-Key: YOUR_API_KEY{
"count": 42,
"syncedAtUtc": "2026-03-25T14:35:00Z",
"username": "octocat"
}{
"statusCode": 401,
"message": "GitHub authentication required. Visit /api/github/auth to connect."
}{
"statusCode": 429,
"message": "GitHub API rate limit exceeded. Resets at 2026-03-25T15:00:00Z."
}{
"statusCode": 502,
"message": "GitHub API error: 500 — Internal Server Error"
}Returns all locally stored GitHub repositories.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
GET /api/github/repos
X-Api-Key: YOUR_API_KEY{
"repositories": [
{
"name": "my-repo",
"fullName": "octocat/my-repo",
"description": "A sample repository",
"isPrivate": false,
"defaultBranch": "main",
"htmlUrl": "https://github.com/octocat/my-repo",
"updatedAtUtc": "2026-03-20T10:00:00Z"
}
],
"count": 1,
"syncedAtUtc": "2026-03-25T14:35:00Z"
}{
"repositories": [],
"count": 0,
"syncedAtUtc": null
}Returns details for a single stored repository by name.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Repository name (e.g., my-repo) |
GET /api/github/repos/my-repo
X-Api-Key: YOUR_API_KEY{
"name": "my-repo",
"fullName": "octocat/my-repo",
"description": "A sample repository",
"isPrivate": false,
"defaultBranch": "main",
"htmlUrl": "https://github.com/octocat/my-repo",
"updatedAtUtc": "2026-03-20T10:00:00Z",
"syncedAtUtc": "2026-03-25T14:35:00Z"
}{
"statusCode": 404,
"message": "Repository 'unknown-repo' not found. Run POST /api/github/repos/sync to refresh."
}Creates a new issue on the specified GitHub repository.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | application/json |
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Repository name (e.g., my-repo) |
| Field | Type | Required | Description |
|---|---|---|---|
title |
string |
Yes | Issue title. |
body |
string |
No | Issue body (Markdown supported). |
labels |
string[] |
No | List of label names to apply. |
POST /api/github/repos/my-repo/issues
X-Api-Key: YOUR_API_KEY
Content-Type: application/json{
"title": "Fix login page styling"
}POST /api/github/repos/my-repo/issues
X-Api-Key: YOUR_API_KEY
Content-Type: application/json{
"title": "Add dark mode support",
"body": "## Description\n\nUsers have requested a dark mode option.",
"labels": ["enhancement", "ui"]
}{
"number": 42,
"title": "Add dark mode support",
"htmlUrl": "https://github.com/octocat/my-repo/issues/42",
"state": "open"
}{
"statusCode": 400,
"message": "One or more validation errors occurred.",
"errors": {
"title": ["'title' must not be empty."]
}
}{
"statusCode": 404,
"message": "Repository 'unknown-repo' not found. Run POST /api/github/repos/sync to refresh."
}{
"statusCode": 401,
"message": "GitHub authentication required. Visit /api/github/auth to connect."
}{
"statusCode": 429,
"message": "GitHub API rate limit exceeded. Resets at 2026-03-25T15:00:00Z."
}{
"statusCode": 502,
"message": "GitHub API error: 422 — Validation Failed (Issues are disabled for this repository)"
}Composites a 1280×720 PNG hero image with title (and optional subtitle) overlaid on a faded background, with the author's face in the lower-right and logo in the lower-left. Returns the PNG as binary image data.
| Detail | Value |
|---|---|
| Auth | X-Api-Key header |
| Content-Type | multipart/form-data |
| Response | image/png binary (1280×720) |
| Field | Type | Required | Constraints |
|---|---|---|---|
title |
string |
Yes | Non-empty, max 200 characters |
subtitle |
string |
No | Max 300 characters |
backgroundImage |
file |
No | JPEG or PNG, max 10 MB |
POST /api/hero-image
X-Api-Key: YOUR_API_KEY
Content-Type: multipart/form-datacurl -X POST http://localhost:5000/api/hero-image \
-H "X-Api-Key: YOUR_API_KEY" \
-F "title=Getting Started with .NET 10" \
-o hero.pngcurl -X POST http://localhost:5000/api/hero-image \
-H "X-Api-Key: YOUR_API_KEY" \
-F "title=Blazor Deep Dive" \
-F "subtitle=Part 3: Component Lifecycle" \
-o hero.pngcurl -X POST http://localhost:5000/api/hero-image \
-H "X-Api-Key: YOUR_API_KEY" \
-F "title=Azure Functions" \
-F "backgroundImage=@my-background.jpg" \
-o hero.pngBinary PNG image data (1280×720). Set the Accept header or rely on Content-Type: image/png to handle the binary response.
{
"statusCode": 400,
"message": "One or more validation errors occurred.",
"errors": {
"title": ["Title is required."]
}
}Returned when a background image file is uploaded but cannot be decoded as a valid image.
{
"statusCode": 422,
"message": "Failed to decode custom background image."
}{
"statusCode": 500,
"message": "An unexpected error occurred during image generation."
}All configuration is managed through the Aspire AppHost project. Do not add appsettings.json entries in other projects. Use User Secrets in the AppHost for sensitive values during development.
Each setting has up to three identifiers:
| Name | Format | Example | Used In |
|---|---|---|---|
| Config Key | Colon-separated | Bluesky:Handle |
IOptions<T>, appsettings.json |
| Aspire Parameter | Kebab-case | bluesky-handle |
builder.AddParameter(), User Secrets |
| Environment Variable | Double-underscore | Bluesky__Handle |
.WithEnvironment(), Azure App Service, containers |
Settings shown with — for the Aspire parameter are hardcoded in the AppHost (e.g., connection strings that use Azurite locally).
| Config Key | Aspire Parameter | Environment Variable | Required | Description |
|---|---|---|---|---|
Bluesky:Handle |
bluesky-handle |
Bluesky__Handle |
Yes | Your Bluesky handle (e.g., your-handle.bsky.social). |
Bluesky:AppPassword |
bluesky-app-password |
Bluesky__AppPassword |
Yes | App password generated from Bluesky settings. |
Bluesky:ServiceUrl |
— | Bluesky__ServiceUrl |
No | Defaults to https://bsky.social. Hardcoded in AppHost. |
| Config Key | Aspire Parameter | Environment Variable | Required | Description |
|---|---|---|---|---|
Mastodon:InstanceUrl |
mastodon-instance-url |
Mastodon__InstanceUrl |
Yes | Your Mastodon instance URL (e.g., https://mastodon.social). |
Mastodon:AccessToken |
mastodon-access-token |
Mastodon__AccessToken |
Yes | Access token from your Mastodon application. |
| Config Key | Aspire Parameter | Environment Variable | Required | Description |
|---|---|---|---|---|
LinkedIn:ClientId |
linkedin-client-id |
LinkedIn__ClientId |
Yes | OAuth application client ID. |
LinkedIn:ClientSecret |
linkedin-client-secret |
LinkedIn__ClientSecret |
Yes | OAuth application client secret. |
LinkedIn:AuthorUrn |
linkedin-author-urn |
LinkedIn__AuthorUrn |
Yes | URN of the posting author (e.g., urn:li:person:abc123). |
LinkedIn:ApiBaseUrl |
linkedin-api-base-url |
LinkedIn__ApiBaseUrl |
No | Defaults to https://api.linkedin.com. |
LinkedIn:OAuthBaseUrl |
linkedin-oauth-base-url |
LinkedIn__OAuthBaseUrl |
No | Defaults to https://www.linkedin.com. |
LinkedIn:TokenStorage:ConnectionString |
— | LinkedIn__TokenStorage__ConnectionString |
No | Azure Table Storage connection string. Hardcoded to Azurite in AppHost. |
LinkedIn:TokenStorage:TableName |
linkedin-token-storage-table-name |
LinkedIn__TokenStorage__TableName |
No | Defaults to linkedintokens. |
| Config Key | Aspire Parameter | Environment Variable | Required | Description |
|---|---|---|---|---|
GitHub:ClientId |
github-client-id |
GitHub__ClientId |
Yes | GitHub OAuth application client ID. |
GitHub:ClientSecret |
github-client-secret |
GitHub__ClientSecret |
Yes | GitHub OAuth application client secret. |
GitHub:ApiBaseUrl |
github-api-base-url |
GitHub__ApiBaseUrl |
No | Defaults to https://api.github.com. |
GitHub:OAuthBaseUrl |
github-oauth-base-url |
GitHub__OAuthBaseUrl |
No | Defaults to https://github.com. |
GitHub:TokenStorage:ConnectionString |
— | GitHub__TokenStorage__ConnectionString |
No | Azure Table Storage connection string. Hardcoded to Azurite in AppHost. |
GitHub:TokenStorage:TableName |
github-token-storage-table-name |
GitHub__TokenStorage__TableName |
No | Defaults to githubtokens. |
GitHub:RepoStorage:ConnectionString |
— | GitHub__RepoStorage__ConnectionString |
No | Azure Table Storage connection string. Hardcoded to Azurite in AppHost. |
GitHub:RepoStorage:TableName |
github-repo-storage-table-name |
GitHub__RepoStorage__TableName |
No | Defaults to githubrepositories. |
| Config Key | Aspire Parameter | Environment Variable | Required | Description |
|---|---|---|---|---|
Auth:ApiKey |
auth-api-key |
Auth__ApiKey |
Yes | The API key clients must send in the X-Api-Key header. |
| Config Key | Aspire Parameter | Environment Variable | Required | Default | Description |
|---|---|---|---|---|---|
BlogPromotion:FeedUrl |
blog-promotion-feed-url |
BlogPromotion__FeedUrl |
Yes | — | Absolute URL of the RSS feed. |
BlogPromotion:RecentDaysWindow |
blog-promotion-recent-days-window |
BlogPromotion__RecentDaysWindow |
No | 7 |
Days to look back for new entries. |
BlogPromotion:EnableReminderPosts |
blog-promotion-enable-reminder-posts |
BlogPromotion__EnableReminderPosts |
No | false |
Post reminders for previously promoted entries. |
BlogPromotion:ReminderDelayHours |
blog-promotion-reminder-delay-hours |
BlogPromotion__ReminderDelayHours |
No | 24 |
Hours before a reminder is eligible. |
BlogPromotion:TableStorage:ConnectionString |
— | BlogPromotion__TableStorage__ConnectionString |
No | — | Azure Table Storage connection string. Hardcoded to Azurite in AppHost. |
BlogPromotion:TableStorage:AccountEndpoint |
— | — | No | — | Azure Table Storage account endpoint (when ConnectionString is not set). |
BlogPromotion:TableStorage:TableName |
blog-promotion-table-storage-table-name |
BlogPromotion__TableStorage__TableName |
No | blogpostpromotions |
Table name for promotion tracking records. |
BlogPromotion:TableStorage:PartitionKey |
blog-promotion-table-storage-partition-key |
BlogPromotion__TableStorage__PartitionKey |
No | blog-promotion |
Partition key for promotion records. |
Scheduled posts are stored in Azure Table Storage and processed on-demand via /api/social-posts/scheduled/process. At least one storage option must be configured.
| Config Key | Aspire Parameter | Environment Variable | Required | Default | Description |
|---|---|---|---|---|---|
ScheduledSocialPost:MaxBatchSize |
scheduled-social-post-max-batch-size |
ScheduledSocialPost__MaxBatchSize |
No | 100 |
Max posts to process per request (1–1000). |
ScheduledSocialPost:TableStorage:ConnectionString |
— | ScheduledSocialPost__TableStorage__ConnectionString |
No* | — | Azure Table Storage connection string. See note below. |
ScheduledSocialPost:TableStorage:AccountEndpoint |
— | ScheduledSocialPost__TableStorage__AccountEndpoint |
No* | — | Azure Table Storage account endpoint (e.g., https://myaccount.table.core.windows.net). Use with managed identity. |
ScheduledSocialPost:TableStorage:TableName |
scheduled-social-post-table-storage-table-name |
ScheduledSocialPost__TableStorage__TableName |
No | scheduledsocialposts |
Table name for scheduled post records. |
ScheduledSocialPost:TableStorage:PartitionKey |
scheduled-social-post-table-storage-partition-key |
ScheduledSocialPost__TableStorage__PartitionKey |
No | scheduled-social-post |
Partition key for scheduled post records. |
Storage Configuration Note:
Either ConnectionString or AccountEndpoint must be set. In production, you can reuse the same storage account connection string across LinkedIn token storage, blog promotion, and scheduled posts — they use different table names, so no conflict occurs. For example, if you already have LinkedIn__TokenStorage__ConnectionString configured, you can set:
ScheduledSocialPost__TableStorage__ConnectionString = (same value as LinkedIn__TokenStorage__ConnectionString)
- Option A (Shared Connection String): Set
ScheduledSocialPost__TableStorage__ConnectionStringto your Azure Storage account connection string (simplest for single-account deployments). - Option B (Managed Identity): Set
ScheduledSocialPost__TableStorage__AccountEndpointand configure managed identity on your Azure resource. - Table Name Rule:
ScheduledSocialPost__TableStorage__TableNamemust be 3-63 chars, start with a letter, and contain letters/numbers only. Use lowercase values such asscheduledsocialposts.
Tips are stored in Azure Table Storage and posted on-demand via /api/social-posts/tips/post. At least one storage option must be configured.
Imported table rows can use either API-created Pascal-case fields (Category, Tip, MoreInfoUrl, LastPostedDate) or upload-friendly camel-case fields (category, tip, moreInfoUrl/url, lastPostedDate). The Azure Table system Timestamp is ignored for repost eligibility; only the explicit LastPostedDate/lastPostedDate property controls whether a tip has been posted recently.
| Config Key | Aspire Parameter | Environment Variable | Required | Default | Description |
|---|---|---|---|---|---|
TipOfDay:RepostCooldownDays |
tip-of-day-repost-cooldown-days |
TipOfDay__RepostCooldownDays |
No | 180 |
Minimum days before a previously posted tip can be selected again. |
TipOfDay:TableStorage:ConnectionString |
— | TipOfDay__TableStorage__ConnectionString |
No* | — | Azure Table Storage connection string. Hardcoded to Azurite in AppHost. |
TipOfDay:TableStorage:AccountEndpoint |
— | TipOfDay__TableStorage__AccountEndpoint |
No* | — | Azure Table Storage account endpoint. Use with managed identity. |
TipOfDay:TableStorage:TableName |
tip-of-day-table-storage-table-name |
TipOfDay__TableStorage__TableName |
No | tipofthedaytips |
Table name for tip records. |
TipOfDay:TableStorage:PartitionKey |
tip-of-day-table-storage-partition-key |
TipOfDay__TableStorage__PartitionKey |
No | tip-of-the-day |
Partition key for tip records. |
Either ConnectionString or AccountEndpoint must be set. The table name must be 3-63 characters, start with a letter, and contain letters/numbers only.
| Config Key | Aspire Parameter | Environment Variable | Required | Default | Description |
|---|---|---|---|---|---|
NasaApod:ApiKey |
nasa-apod-api-key |
NasaApod__ApiKey |
Yes | — | NASA API key. Register free at https://api.nasa.gov/. |
NasaApod:BaseUrl |
— | — | No | https://api.nasa.gov/planetary/apod |
NASA APOD API base URL. Not mapped in AppHost. |
No API key required — NASA GIBS is publicly accessible.
| Config Key | Aspire Parameter | Environment Variable | Required | Default | Description |
|---|---|---|---|---|---|
NasaGibs:BaseUrl |
gibs-base-url |
NasaGibs__BaseUrl |
No | https://wvs.earthdata.nasa.gov/api/v1/snapshot |
GIBS Worldview Snapshot API base URL. |
NasaGibs:DefaultLayer |
gibs-default-layer |
NasaGibs__DefaultLayer |
No | MODIS_Terra_CorrectedReflectance_TrueColor |
Default imagery layer. |
NasaGibs:BboxSouth |
gibs-bbox-south |
NasaGibs__BboxSouth |
No | 38.40 |
Southern boundary (latitude). |
NasaGibs:BboxWest |
gibs-bbox-west |
NasaGibs__BboxWest |
No | -84.82 |
Western boundary (longitude). |
NasaGibs:BboxNorth |
gibs-bbox-north |
NasaGibs__BboxNorth |
No | 42.32 |
Northern boundary (latitude). |
NasaGibs:BboxEast |
gibs-bbox-east |
NasaGibs__BboxEast |
No | -80.52 |
Eastern boundary (longitude). |
NasaGibs:ImageWidth |
gibs-image-width |
NasaGibs__ImageWidth |
No | 1024 |
Snapshot image width in pixels. |
NasaGibs:ImageHeight |
gibs-image-height |
NasaGibs__ImageHeight |
No | 768 |
Snapshot image height in pixels. |
Email notifications are sent when social media post processes fail. The system includes automatic rate limiting to prevent email flooding.
| Config Key | Aspire Parameter | Environment Variable | Required | Default | Description |
|---|---|---|---|---|---|
Email:Enabled |
email-enabled |
Email__Enabled |
No | false |
Enable email failure notifications. |
Email:SmtpHost |
email-smtp-host |
Email__SmtpHost |
Yes* | — | SMTP server hostname (e.g., smtp.gmail.com). |
Email:SmtpPort |
email-smtp-port |
Email__SmtpPort |
No | 587 |
SMTP server port (typically 587 for TLS, 465 for SSL). |
Email:UseSsl |
email-use-ssl |
Email__UseSsl |
No | true |
Enable SSL/TLS encryption. |
Email:Username |
email-username |
Email__Username |
Yes* | — | SMTP authentication username. |
Email:Password |
email-password |
Email__Password |
Yes* | — | SMTP authentication password or app password. |
Email:FromAddress |
email-from-address |
Email__FromAddress |
Yes* | — | Email address to send notifications from. |
Email:FromName |
email-from-name |
Email__FromName |
Yes* | — | Display name for the sender. |
Email:ToAddress |
email-to-address |
Email__ToAddress |
Yes* | — | Email address to receive notifications. |
*Required only when Email:Enabled is true.
Rate limiting is always active when email notifications are enabled. No additional configuration is required.
| Setting | Value | Description |
|---|---|---|
| Limit | 1 email per post type per 24 hours | Each failure type tracked independently |
| Post Types | NASA APOD, NASA GIBS Satellite, RSS Blog Post, Scheduled Social Post, Blog Promotion | Separate limits for each |
| Storage | Azure Table Storage (EmailRateLimit table) or in-memory |
Automatic selection based on environment |
| Behavior | First failure sends email, subsequent failures within 24h are suppressed | Prevents notification flooding |
Storage Selection:
- Production (Azure Storage configured): Uses Azure Table Storage for persistent, distributed-safe rate limiting
- Development (Azure Storage not configured): Uses in-memory storage (resets on app restart)
- Storage Account: Rate limiting automatically uses the same Azure Storage account as Scheduled Posts if configured
- Table Name:
EmailRateLimit(created automatically)
Resetting Rate Limits:
- Azure Storage: Delete rows from the
EmailRateLimittable - In-Memory: Restart the application
{
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"UseSsl": true,
"Username": "yourname@gmail.com",
"Password": "your-app-password",
"FromAddress": "yourname@gmail.com",
"FromName": "BarretApi Notifications",
"ToAddress": "admin@yourdomain.com"
}
}Note: For Gmail, you must enable 2-factor authentication and generate an app password at https://myaccount.google.com/apppasswords.
Complete Documentation: See docs/EMAIL_NOTIFICATIONS.md and docs/EMAIL_RATE_LIMITING.md for detailed information on email notifications, rate limiting, troubleshooting, and production deployment.
The deployment workflow runs the solution tests in Release configuration before publishing. A failing test prevents deployment.
Pushing to main builds, publishes, and deploys the API to the barretapi Azure Web App via .github/workflows/main_barretapi.yml. The workflow can also be triggered by hand with workflow_dispatch. Azure authentication uses OIDC federated credentials (azure/login@v2) — there is no publish profile or password secret, only the client/tenant/subscription IDs.
Build and deploy are a single job. They were split so the publish output could cross between two GitHub-hosted runners as an artifact; both halves now run on the same machine, so that upload/download round-trip was removed.
Note that CI does not run the test suites — it only proves the API project compiles and publishes. Run them locally before pushing:
dotnet testThe workflow runs on a self-hosted runner registered to this repository, not on GitHub-hosted minutes. The runner is a Docker container defined in C:\projects\RunBarretRun\compose.yaml (service barretapi-runner), sharing the .NET SDK image used by the DungeonHostv4 runner. It is a separate container only because runners on a personal account are scoped to a single repository.
Start it (the first run needs a registration token; afterwards the registration persists in the barretapi-runner-data volume):
$env:BARRETAPI_RUNNER_TOKEN = (gh api -X POST repos/barretb/BarretApi/actions/runners/registration-token --jq .token)
docker compose -f C:\projects\RunBarretRun\compose.yaml up -d --build barretapi-runnerTwo consequences worth knowing:
- Jobs only run while the container is up. A push still queues a run, but it expires after about 24 hours if the machine is off — so a deploy can silently never happen. Check the Actions tab if a change does not appear in production.
- The workspace persists between runs. The publish step clears its output directory first, since
dotnet publishwrites over stale files rather than replacing the directory.
The container carries no Docker socket. If CI ever needs to run the Testcontainers-based integration tests, mount /var/run/docker.sock into the service the way the DungeonHostv4 runner does.
Always call production endpoints using https://. Using http:// may result in redirect behavior where clients retry with the wrong method and receive 405 Method Not Allowed.
Before deploying to production, ensure at least one of the following is configured:
ScheduledSocialPost__TableStorage__ConnectionString— Can reuse your existing Azure Storage account (same as LinkedIn or Blog Promotion if using one storage account)ScheduledSocialPost__TableStorage__AccountEndpoint— Set to your table storage account endpoint and configure managed identity
Unified Storage Pattern (Recommended):
If you have a single Azure Storage account for all features:
# Set the same connection string for all table storage features
export LinkedIn__TokenStorage__ConnectionString="DefaultEndpointsProtocol=..."
export BlogPromotion__TableStorage__ConnectionString="DefaultEndpointsProtocol=..." # Same
export ScheduledSocialPost__TableStorage__ConnectionString="DefaultEndpointsProtocol=..." # SameEach feature uses its own table name (linkedintokens, blogpostpromotions, scheduledsocialposts), so there's no conflict.
Error: If neither ConnectionString nor AccountEndpoint is set, the API will fail at startup with OptionsValidationException: ScheduledSocialPost:TableStorage:ConnectionString or AccountEndpoint must be configured.
If scheduled-post requests fail with a table initialization error, verify ScheduledSocialPost__TableStorage__TableName is lowercase alphanumeric (example: scheduledsocialposts) and restart the app after updating app settings.
Some production environments restrict table creation at runtime. In that case, pre-create the scheduled-post table and grant the app identity table data-plane permissions before calling scheduled endpoints.
JobSchedulerOptions is validated with .ValidateOnStart(), so this configuration is required before deploying this build at all — whether or not JobScheduler:Enabled is true. A missing value fails application startup, taking down the entire API, not just the scheduler. Ensure at least one of the following is configured:
JobScheduler__TableStorage__ConnectionString— can reuse your existing Azure Storage account (same as LinkedIn, Blog Promotion, or Scheduled Posts if using one storage account).JobScheduler__TableStorage__AccountEndpoint— set to your table storage account endpoint and configure managed identity. This option is not wired to an AppHost parameter; set it directly as an Azure App Service application setting.
The scheduler uses two tables, both configurable and defaulting to lowercase names: scheduledjobs (JobScheduler__TableStorage__JobsTableName) for job definitions and jobruns (JobScheduler__TableStorage__RunsTableName) for run history.
Unified Storage Pattern: as with the other table-storage features, you can point JobScheduler__TableStorage__ConnectionString at the same storage account used everywhere else in this app — each feature uses its own table name, so there's no conflict.
Always On: the barretapi Web App must have Always On enabled before any job is turned on in production. Without it, App Service unloads the process when idle and the background tick loop stops — jobs simply stop firing, silently, until the next request happens to wake the app. This is a portal setting (Web App → Configuration → General settings), not something set in code or the AppHost.
The Power Automate flows this scheduler replaces are being retired incrementally; see docs/JOB_SCHEDULER.md for the migration sequence. Both triggers call the same services, so a flow and its equivalent job can coexist safely during cutover.
- Add LinkedIn configuration values to deployment settings before enabling LinkedIn in client requests.
- Complete the authenticated browser setup described under LinkedIn OAuth, then authorize the application.
- Retrieve your profile URN from
/api/linkedin/profileand setLinkedIn:AuthorUrntourn:li:person:<sub>. - Validate with a
linkedin-only request first, then test mixed-platform requests. - Confirm mixed-platform failure handling returns
207when LinkedIn fails and another platform succeeds.
- Use least-privilege LinkedIn app permissions.
- Rotate access tokens regularly.
- Never log or return token values in API responses.
- Store all secrets in Aspire AppHost User Secrets during development and in secure deployment configuration for production.