Documentation
¶
Overview ¶
Package outstand is a stdlib-only client for the Outstand social-posting API (https://outstand.so), together with its webhook-verification and fan-out-policy helpers. It is shared across Pigfox products (pigfox, marketverdict): one vocabulary of apps and connected social-account ids spans every integration.
Client ¶
NewClient returns a Client (a Gateway) bound to an OUTSTAND_API_KEY bearer token. It creates, fetches, and deletes posts and lists/health-checks connected social accounts. A decoded Post is a strict allowlist: the vendor's embedded per-network OAuth tokens are never lifted into it, so a token cannot reach a decoded value, a log line, an error, or the ledger.
Webhooks ¶
ConstructEvent verifies an Outstand webhook delivery — a constant-time HMAC-SHA256 over the raw body against OUTSTAND_WEBHOOK_SECRET — and only then decodes it into an Event. IsSignatureError separates a signature/format rejection (answer 400) from a post-verification parse failure (ack 200, so Outstand does not retry a payload the decoder cannot read).
Identity and fan-out policy ¶
DefaultFanout maps an App to the AccountID set a default publish fans out to; OwnedBy reports which app owns an account (a foreign id on the shared Outstand organization is acked-and-ignored). These ids are non-secret and live in code as guard-testable publish policy — secrets never do.
Versioning ¶
This module follows semantic versioning. New functionality arrives as additions; no exported name, signature, or documented behavior changes without a major-version bump.
Index ¶
- Constants
- Variables
- func IsSignatureError(err error) bool
- type APIError
- type Account
- type AccountID
- type AccountResult
- type App
- type Client
- func (c *Client) CheckAccountHealth(ctx context.Context, id AccountID) (bool, error)
- func (c *Client) CreatePost(ctx context.Context, in CreatePostInput) (Post, error)
- func (c *Client) DeletePost(ctx context.Context, id string) error
- func (c *Client) GetPost(ctx context.Context, id string) (Post, error)
- func (c *Client) ListAccounts(ctx context.Context) ([]Account, error)
- type Container
- type CreatePostInput
- type Event
- type FacebookOptions
- type FieldNameMode
- type Gateway
- type LinkedInOptions
- type Post
- type PostAccount
Constants ¶
const ( EventPostPublished = "post.published" EventPostError = "post.error" EventAccountTokenExpired = "account.token_expired" EventTest = "test" EventImportCompleted = "import.completed" EventImportFailed = "import.failed" )
Event type strings Outstand delivers (confirmed against the Outstand webhook docs, 2026-07). post.published fires when a post succeeded on at least one platform; post.error when it failed across all targeted accounts; account.token_expired when an OAuth refresh failed. "test" is the dashboard Test button. import.* are acked-and-ignored (no local effect in this service).
Variables ¶
var ( ErrEmptySecret = errors.New("outstand webhook: empty signing secret") ErrMissingSig = errors.New("outstand webhook: missing signature header") ErrMalformedSig = errors.New(`outstand webhook: signature header not "sha256=<hex>"`) ErrSignatureFailed = errors.New("outstand webhook: signature mismatch") )
Signature/format sentinels. IsSignatureError reports whether an error is one of these — the handler answers 400 for those and acks (200) any OTHER ConstructEvent error (an authentic body whose JSON we cannot parse), so Outstand does not retry a payload our decoder can't read.
var AccountEncoderField = FieldAccounts
AccountEncoderField is the LOCKED create-post account-id key, set from the STEP 2 empirical verification: the live API requires "accounts" (docs' socialAccountIds 400s). Recorded discrepancy — see the Directive-4 ledger note.
var DefaultFanout = map[App][]AccountID{ AppPigfox: {AccountPigfoxFacebook, AccountPigfoxLinkedIn}, AppMarketVerdict: {AccountMVFacebook, AccountMVLinkedIn}, }
DefaultFanout maps an app to the social accounts a default publish fans out to. pigfox is WIRED; marketverdict is DEFINED but NOT referenced by any publish path (Directive 4+). The personal LinkedIn is intentionally absent from every set.
Functions ¶
func IsSignatureError ¶
IsSignatureError reports whether err is a signature/format failure (→ 400) as opposed to a post-verification JSON parse failure (→ ack 200).
Types ¶
type APIError ¶
APIError is the typed vendor error: the operation, the HTTP status, and a bounded body excerpt (never the full body, never a secret). Returned for any non-2xx.
type AccountID ¶
type AccountID string
AccountID is an Outstand social-account id. Non-secret and stable.
const ( // Pigfox LLC — the default-fan-out identities. AccountPigfoxFacebook AccountID = "WBh2z" // Pigfox LLC (facebook page) AccountPigfoxLinkedIn AccountID = "yVFhj" // pigfox-llc (linkedin organization) // Market Verdict — reserved; defined but NOT wired to any publish path yet. AccountMVFacebook AccountID = "GvKip" // Market Verdict (facebook page) AccountMVLinkedIn AccountID = "OSRXv" // marketverdict-app (linkedin organization) // Peter Sjölin's personal LinkedIn — opt-in ONLY, per standing decision. It is // deliberately in NO DefaultFanout set; TestPersonalLinkedInNeverInDefaultFanout // enforces that. AccountPeterPersonalLinkedIn AccountID = "g35ix" // Peter Sjölin (linkedin personal) )
type AccountResult ¶
AccountResult is one social account's outcome within a post.* event.
type App ¶
type App string
App is the product discriminator, matching the locked Stripe metadata contract strings ("pigfox" / "marketverdict") so one vocabulary spans both integrations.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the real Gateway over net/http. Each instance owns its bearer key and a client with clientTimeout. do is a seam over the round-trip so tests never hit the network.
func NewClient ¶
NewClient builds a Client bound to apiKey (Authorization: Bearer) against the production base URL.
func (*Client) CheckAccountHealth ¶
CheckAccountHealth reports whether an account is live (isActive==1), derived from ListAccounts. An id Outstand does not return is an error (unknown account).
func (*Client) CreatePost ¶
CreatePost creates a post targeting in.AccountIDs. The account-id key is the STEP-2-locked AccountEncoderField.
func (*Client) DeletePost ¶
DeletePost deletes (and cancels, if scheduled) a post by id.
type Container ¶
type Container struct {
Content string `json:"content"`
}
Container is one content block of a post. Outstand posts carry an ordered list of content containers.
type CreatePostInput ¶
type CreatePostInput struct {
AccountIDs []AccountID
Containers []Container
ScheduledAt *time.Time
LinkedIn *LinkedInOptions
Facebook *FacebookOptions
}
CreatePostInput is the IDS-ONLY create-post request. AccountIDs is []AccountID (the constants.go type) by design: no raw string / network-name parameter exists anywhere in the public client surface, so a caller cannot target an account by an unvalidated name.
type Event ¶
type Event struct {
Type string // event type (EventPostPublished, ...); "" if unrecognizable
PostID string // correlation id for post.* events; "" otherwise
AccountID AccountID // affected account for account.* events; "" otherwise
Timestamp string // vendor event timestamp (opaque); "" if absent
Results []AccountResult // per-account outcomes on post.* events; may be empty
Raw json.RawMessage // full verified payload, for dedup storage and debug
}
Event is a verified, decoded Outstand webhook. The decoder (UnmarshalJSON) is deliberately tolerant of key casing and nesting — Outstand's own JSON mixes snake_case and camelCase (e.g. isActive / created_at / network_unique_id), so field extraction survives minor shape differences. Authenticity is already guaranteed by the HMAC in ConstructEvent, so tolerant decoding is safe here.
func ConstructEvent ¶
ConstructEvent verifies an Outstand webhook delivery and decodes it. sigHeader is the X-Outstand-Signature value ("sha256=<hex>"); the HMAC-SHA256 is computed over the RAW body bytes with the shared secret and compared in constant time (hmac.Equal). Only on a verified signature is the body unmarshalled. Each signature/format failure returns a distinct sentinel so a rejection is diagnosable from the log alone; a JSON error after successful verification is returned as-is (IsSignatureError == false) so the caller can ack rather than trigger retries.
func (*Event) UnmarshalJSON ¶
UnmarshalJSON decodes an Outstand webhook envelope, tolerating snake/camel key casing and both top-level and nested-in-"data" placement of post_id / account_id / timestamp. Unknown shapes decode to a zero-valued Event (empty Type), which the dispatcher acks-and-ignores rather than erroring on.
type FacebookOptions ¶
type FacebookOptions struct {
}
type FieldNameMode ¶
type FieldNameMode int
FieldNameMode selects which JSON key carries the target account ids in a create-post request. Outstand's docs say socialAccountIds, but the LIVE API (STEP 2 probe, 2026-07-16) rejects that with 400 (path ["accounts"], "Required") and accepts "accounts". AccountEncoderField is locked to the verified key.
const ( FieldSocialAccountIDs FieldNameMode = iota // "socialAccountIds" (per docs — REJECTED live) FieldAccounts // "accounts" (what the live API requires) )
type Gateway ¶
type Gateway interface {
CreatePost(ctx context.Context, in CreatePostInput) (Post, error)
GetPost(ctx context.Context, id string) (Post, error)
DeletePost(ctx context.Context, id string) error
ListAccounts(ctx context.Context) ([]Account, error)
CheckAccountHealth(ctx context.Context, id AccountID) (bool, error)
}
Gateway abstracts the Outstand API surface this subsystem needs so callers (the publish worker, reconciliation) can be tested against a fake and never touch the live network. Mirrors stripeGateway.
type LinkedInOptions ¶
type LinkedInOptions struct {
Visibility string `json:"visibility,omitempty"` // e.g. "PUBLIC"
}
LinkedInOptions / FacebookOptions are per-network option structs. Only these two networks are modeled for now; others are added when a caller needs them.
type Post ¶
type Post struct {
ID string
Status string
TargetedAccounts []AccountID
SocialAccounts []PostAccount
}
Post is a decoded post resource (the fields this subsystem needs). It is an ALLOWLIST: only these fields are lifted from the vendor JSON. The full body is NEVER retained — Outstand's post resource embeds each connected account's OAuth tokens under socialAccounts[].network_data, and dropping the raw body is what keeps those tokens out of Post, logs, the ledger, tests, and any String()/error path.
type PostAccount ¶
type PostAccount struct {
Status string
Error string
PublishedAt string
PlatformPostID string
PlatformPostURL string
}
PostAccount is the per-network delivery state for one targeted account — an ALLOWLIST of non-secret fields. network_data (access_token / refresh_token / page_access_token) is DELIBERATELY not decoded, so a token can never reach a decoded Post.