shiftlabs Start free
Menu AWS and Google Cloud events

AWS and Google Cloud events

EventBridge, SNS and Pub/Sub can each call an HTTPS address when something happens. Give them a trigger URL, and every event starts a run.

What you'll build

One workflow, cloud-events, with a trigger for each source: an EventBridge rule through an API destination, an SNS topic through an HTTPS subscription, and a Pub/Sub topic through a push subscription. The workflow reads each source's format, confirms the SNS subscription itself, and handles each message once, since these services can deliver a message more than once.

  1. Deploy the workflow

    It works out the source from the body.

  2. Create a trigger for each source

    One per source, so you can rotate one URL without touching the others.

  3. Point each source at its trigger

    An API destination, an HTTPS subscription or a push subscription.

  4. Send a test message

    Then find its run.

shiftlabs doesn't read queues itself. A queue you pull from, such as SQS or Kafka, can reach a trigger the same way through an EventBridge pipe with an API destination as its target.

Write the workflow

A trigger passes the body as payload: parsed JSON when it's sent as application/json, as EventBridge and Pub/Sub send it, and the raw text otherwise. SNS sends text/plain, so its payload is a string of JSON.

cloud-events.ts
import { workflow } from "shiftlabs";

type Message = { source: string; id: string; body: any };

// One delivery from EventBridge, SNS or Pub/Sub, as the trigger passed it.
function read(payload: any): Message {
  if (typeof payload === "string") {
    const sns = JSON.parse(payload);
    return { source: "sns", id: sns.MessageId, body: sns };
  }
  if (payload.message?.messageId) {
    const bytes = Uint8Array.from(atob(payload.message.data ?? ""), (c) =>
      c.charCodeAt(0),
    );
    const text = new TextDecoder().decode(bytes);
    return { source: "pubsub", id: payload.message.messageId, body: text };
  }
  return { source: "eventbridge", id: payload.id, body: payload.detail };
}

// A UTC date, offset days ago.
const day = (offset: number) =>
  new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);

export default workflow(async ({ payload, step, state, runId, log }) => {
  const { source, id, body } = read(payload);

  if (source === "sns" && body.Type === "SubscriptionConfirmation") {
    const host = new URL(body.SubscribeURL).hostname;
    if (!host.startsWith("sns.") || !host.endsWith(".amazonaws.com")) {
      throw new Error(`not an SNS address: ${host}`);
    }
    await step.run("confirm subscription", async () => {
      const r = await fetch(body.SubscribeURL);
      if (!r.ok) throw new Error(`SNS answered ${r.status}`);
    });
    return "subscribed";
  }

  const seen = `${source}:${id}`;
  const fresh = await step.run("claim", async () => {
    // Forget ids from before yesterday, so the keys don't pile up.
    for (const key of await state.list({ prefix: "seen:", limit: 20 })) {
      if (key < `seen:${day(1)}`) await state.delete(key);
    }
    for (const d of [day(1), day(0)]) {
      const claimed = await state.get<{ runId: string }>(`seen:${d}:${seen}`);
      if (claimed) return claimed.runId === runId;
    }
    await state.set(`seen:${day(0)}:${seen}`, { runId });
    return true;
  });
  if (!fresh) return "duplicate";

  await step.run("handle", async () => {
    log.info("message", { source, id });
  });
  return id;
});
  • The claim remembers each message id for at least a day. An unsigned trigger drops a repeat by itself only when the sender sets an Idempotency-Key, which these services don't.
  • The claim stores the run's id, so if an attempt stops after state.set, the step's retry finds its own claim and carries on. See State.
  • Each id is one state key, kept for up to two days. Free allows 1,000 keys per workflow and Pro 10,000, so past about 500 messages a day on Free a claim's state.set fails. See Limits.
  • Anyone with the URL can post to an unsigned trigger, so the workflow fetches only an SNS address for a confirmation. Treat the rest of the body as untrusted, as any webhook's.
shellcurl
curl -X PUT https://api.shiftlabs.org/v1/workflows/cloud-events \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" \
  --data-binary @cloud-events.ts

# Run this once per source. Answers 201 {"triggerId", "url"}.
curl -X POST https://api.shiftlabs.org/v1/triggers \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" \
  -H "content-type: application/json" \
  -d '{"workflow": "cloud-events"}'

EventBridge

An API destination sends each event a rule matches to an HTTPS address, as the whole event in JSON: id, source, detail-type, time and detail.

shellaws
# A connection needs an authorization method. The trigger doesn't check it,
# so any API key name and value will do.
aws events create-connection --name shiftlabs \
  --authorization-type API_KEY \
  --auth-parameters '{"ApiKeyAuthParameters":
    {"ApiKeyName": "x-shiftlabs-unused", "ApiKeyValue": "unused"}}'

# A trigger takes 100 deliveries per 10 seconds: send at most 10 a second.
aws events create-api-destination --name shiftlabs-cloud-events \
  --connection-arn <connection ARN> \
  --invocation-endpoint https://hooks.shiftlabs.org/t/<triggerId> \
  --http-method POST \
  --invocation-rate-limit-per-second 10

# The role needs events:InvokeApiDestination on the API destination.
aws events put-targets --rule <rule name> \
  --targets 'Id=shiftlabs,Arn=<API destination ARN>,RoleArn=<role ARN>'

EventBridge waits 5 seconds for an answer, and retries a 429 or a 5xx for up to 24 hours, waiting at least the trigger's Retry-After. It doesn't retry other 4xx answers. Give the rule a dead-letter queue to keep events it couldn't deliver. See API destinations.

SNS

An HTTPS subscription posts each message published to the topic, wrapped in SNS's JSON with Type, MessageId and Message. The first post is a SubscriptionConfirmation, and the workflow above visits its SubscribeURL to confirm it.

shellaws
aws sns subscribe --topic-arn <topic ARN> --protocol https \
  --notification-endpoint https://hooks.shiftlabs.org/t/<triggerId>

# Confirmed once the subscription has an ARN instead of PendingConfirmation.
aws sns list-subscriptions-by-topic --topic-arn <topic ARN>

The message you published is the string in body.Message. SNS's wrapper adds to its size, and a trigger refuses a body over 256 KiB with 413. See HTTP/HTTPS headers and delivery retries.

Pub/Sub

A push subscription posts each message as JSON: message.data holds what you published, in base64, and message.messageId its id. A 202 or 200 from the trigger acknowledges it, and any other answer makes Pub/Sub send it again.

shellgcloud
gcloud pubsub subscriptions create shiftlabs-cloud-events \
  --topic=<topic> \
  --push-endpoint=https://hooks.shiftlabs.org/t/<triggerId>

Pub/Sub resends a message until it's acknowledged or expires, so a message a trigger refuses for good, such as one over 256 KiB, comes back until then. Base64 makes a message about a third larger. Give the subscription a dead-letter topic to set such messages aside. See Push subscriptions.

Check it worked

shellaws, gcloud and curl
aws sns publish --topic-arn <topic ARN> --message '{"order": "ord_123"}'

gcloud pubsub topics publish <topic> --message='{"order": "ord_123"}'

# Each source's run, newest first, with origin "trigger".
curl "https://api.shiftlabs.org/v1/runs?workflow=cloud-events&limit=5" \
  -H "authorization: Bearer $SHIFTLABS_API_KEY"
  • The first SNS run

    Returns subscribed, and the subscription shows an ARN.

  • Every other run

    Returns the message's id, and its logs show message with the source. A repeat of a message returns duplicate.

  • A refused delivery

    A source whose deliveries get 429 is sending faster than the trigger takes them; see Request rates. A 404 means the trigger was revoked or rotated: point the source at the new URL.

Reference

  • Deploy and triggers

    Every answer a trigger gives, and rotating its URL.

  • State

    The values the claim keeps, and their limits.

  • Rate limits and retries

    What a 429 or 503 from a trigger means, and how senders retry.

  • Limits

    Deliveries per trigger and per workspace, and state keys on each plan.