
Product
Socket Threat Intel Now Shows Which Malware Campaigns Were Blocked or Detected
Socket Threat Intel now shows which malware campaigns were blocked or detected in your organization, with links to scans and Firewall events.
@webhooks-cc/sdk
Advanced tools
TypeScript SDK for webhooks.cc — create endpoints, capture requests, assert in tests
TypeScript SDK for webhooks.cc. Create webhook endpoints, capture and search requests, wait for emails and read their codes and links, forward requests and emails to your server or Slack, send signed test webhooks, verify provider signatures, and build webhook tests with less boilerplate.
pnpm add @webhooks-cc/sdk
The package also ships a testing entrypoint, and an email entrypoint with no network code:
import { captureDuring, captureEmailDuring, assertRequest } from "@webhooks-cc/sdk/testing";
import { extractCode, type EmailReceivedEvent } from "@webhooks-cc/sdk/email";
The SDK needs an API key in whcc_... format. You can pass the key directly, but most projects load it from WHK_API_KEY so the same code works locally and in CI.
For local development, set the env var in your shell or .env.local:
export WHK_API_KEY=whcc_...
For GitHub Actions, store the key as a repository secret and expose it in the workflow:
# .github/workflows/test.yml
env:
WHK_API_KEY: ${{ secrets.WHK_API_KEY }}
An agent can capture webhooks before anyone signs up. WebhooksCC.sandbox() registers through auth.md, solves a short proof of work (about 3 seconds) and returns a sandbox: up to 3 endpoints, 25 captured requests each and 100 in all, for 24 hours.
import { WebhooksCC } from "@webhooks-cc/sdk";
const sandbox = await WebhooksCC.sandbox({ clientName: "my-agent" });
const endpoint = await sandbox.endpoints.create();
const request = await sandbox.requests.waitFor(endpoint.slug, { timeout: "60s" });
// Connect it to a human's account: they open the link, sign in and enter the code.
const { verificationUri, userCode } = await sandbox.claim({ email: "dev@example.com" });
console.log(`Open ${verificationUri}, sign in, and enter ${userCode}`);
const client = await sandbox.waitForClaim(); // a WebhooksCC for that account
WebhooksCC.agent.* exposes the auth.md steps one by one. See AI Agents.
import { WebhooksCC, matchAll, matchHeader, matchMethod } from "@webhooks-cc/sdk";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const endpoint = await client.endpoints.create({
name: "stripe-test",
expiresIn: "1h",
});
await yourApp.registerWebhook(endpoint.url!);
await yourApp.triggerCheckout();
const request = await client.requests.waitFor(endpoint.slug, {
timeout: "30s",
match: matchAll(matchMethod("POST"), matchHeader("stripe-signature")),
});
console.log(request.body);
await client.endpoints.delete(endpoint.slug);
const client = new WebhooksCC({
apiKey: "whcc_...",
retry: {
maxAttempts: 3,
backoffMs: 500,
},
hooks: {
onRequest: ({ method, url }) => console.log(method, url),
onResponse: ({ status, durationMs }) => console.log(status, durationMs),
onError: ({ error }) => console.error(error),
},
});
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | required | API key in whcc_... format. Often read from process.env.WHK_API_KEY. |
baseUrl | string | https://webhooks.cc | API base URL |
webhookUrl | string | https://go.webhooks.cc | receiver base URL used by endpoints.send() |
timeout | number | 30000 | request timeout in milliseconds |
retry | RetryOptions | 1 attempt | retries transient failures (see below) |
hooks | ClientHooks | none | lifecycle callbacks for request logging |
emailDomain | string | mailhooks.cc | domain emails.address() builds addresses on |
With retry, GET, PUT and DELETE calls are repeated after a network error, a timeout or a status in retryOn (429, 500, 502, 503 and 504 by default), and any call after a 429, which the API sends before doing anything. A POST or PATCH that failed any other way is not repeated: it may have gone through, and a second endpoints.create() would leave an extra endpoint. Nor is requests.clear() without before, whose repeat would also delete what arrived in between.
client.endpoints: create, list, get, update, delete, send, sendTemplateclient.requests: list, listPaginated, get, waitFor, waitForAll, subscribe, replay, search, count, clear, exportclient.emails: address, list, get, latest, waitFor, waitForAll, sendTest, toJsonclient.forwarding: configure, secret, rotateSecret, test, deliveries, summary, requestDeliveries, redeliver, redeliverFailedclient.templates: listProviders, getclient.teams: list, members, share, unshare, invite, invites.list, invites.accept, invites.declineusage(), sendTo(), buildRequest(), flow(), describe()Create persistent or ephemeral endpoints. You can also attach a mock response at creation time.
const endpoint = await client.endpoints.create({
name: "billing-webhooks",
expiresIn: "12h",
mockResponse: {
status: 202,
body: '{"queued":true}',
headers: { "x-webhooks-cc": "mock" },
},
});
const fetched = await client.endpoints.get(endpoint.slug);
console.log(fetched.isEphemeral, fetched.expiresAt);
await client.endpoints.update(endpoint.slug, {
name: "billing-webhooks-renamed",
mockResponse: null,
});
Answer differently per request with response rules, and echo the request with placeholders. previewResponse shows what a captured request would get back without sending anything:
await client.endpoints.update(endpoint.slug, {
responseRules: [
{
name: "Payment failed",
conditions: [{ field: "body_path", op: "eq", path: "type", value: "invoice.payment_failed" }],
response: { status: 422, body: '{"event": "{{ json.id }}"}', headers: {} },
},
],
mockResponse: { status: 200, body: '{"id": "{{ uuid }}"}', headers: {} },
});
const preview = await client.endpoints.previewResponse(endpoint.slug);
console.log(preview.rule?.index, preview.response?.body);
Send plain test requests through the hosted receiver:
await client.endpoints.send(endpoint.slug, {
method: "POST",
headers: { "content-type": "application/json" },
body: { event: "invoice.paid" },
});
List, paginate, wait, stream, replay, export, and clear captured requests.
const recent = await client.requests.list(endpoint.slug, {
limit: 50,
since: Date.now() - 60_000,
kind: "http", // or "email"; omit for both
});
const page1 = await client.requests.listPaginated(endpoint.slug, { limit: 100 });
const page2 = page1.cursor
? await client.requests.listPaginated(endpoint.slug, { limit: 100, cursor: page1.cursor })
: { items: [], hasMore: false };
const firstMatch = await client.requests.waitFor(endpoint.slug, {
timeout: "20s",
match: matchHeader("stripe-signature"),
});
const allMatches = await client.requests.waitForAll(endpoint.slug, {
count: 3,
timeout: "30s",
match: matchMethod("POST"),
});
for await (const request of client.requests.subscribe(endpoint.slug, { reconnect: true })) {
console.log(request.method, request.path);
}
subscribe() streams over SSE. The server rotates every stream connection after 30 minutes: it
sends a timeout event and closes. With reconnect: true the client follows that rotation
transparently, reconnecting immediately, resuming from the last received request, and deduplicating
replayed events, so the loop above runs until you break out of it, abort the signal, the timeout
expires, or the endpoint is deleted. Rotations do not count against maxReconnectAttempts
(default 5), which only limits recovery from unexpected stream ends and transient errors (with
exponential backoff from reconnectBackoffMs). Without reconnect, the loop ends at the first
rotation, after at most 30 minutes.
An idle watchdog (idleTimeout, default "90s", three server keepalive intervals) detects
half-open sockets: if nothing arrives within that window while waiting, the connection is dropped.
With reconnect: true it is reconnected (counted as a reconnect attempt, with backoff); without it,
the iterator throws TimeoutError. Pass idleTimeout: 0 to disable the watchdog.
for await (const request of client.requests.subscribe(endpoint.slug, {
reconnect: true,
maxReconnectAttempts: 10,
idleTimeout: "2m",
onReconnect: (attempt) => console.warn(`stream reconnect #${attempt}`),
})) {
console.log(request.method, request.path);
}
Replay, export, and clear requests:
await client.requests.replay(firstMatch.id, "http://localhost:3001/webhooks");
const curlExport = await client.requests.export(endpoint.slug, {
format: "curl",
limit: 10,
});
const harExport = await client.requests.export(endpoint.slug, {
format: "har",
since: Date.now() - 3_600_000,
});
await client.requests.clear(endpoint.slug, { before: "24h" });
Search and count use the retained request store rather than the live endpoint request table:
const retained = await client.requests.search({
slug: endpoint.slug,
q: "checkout.session.completed",
from: "7d",
limit: 20,
});
const total = await client.requests.count({
slug: endpoint.slug,
q: "checkout.session.completed",
from: "7d",
});
search() returns SearchResult[]. Their id field is synthetic and is not valid for requests.get() or requests.replay().
requests.list(), listPaginated(), search() and count() take kind: "http" | "email".
waitFor() and waitForAll() look back five minutes by default, so a request that arrived just
before the call is found; pass since to change that.
Every endpoint on an account also receives email at <slug>@mailhooks.cc, and at
<slug>+<tag>@mailhooks.cc for any tag. Guest endpoints receive none. A captured email is a
request with kind: "email", method: "EMAIL", the recipient address as path, the raw message
as body, and the parsed message as email (typed EmailRequest). Each email counts as one
request against your quota.
Give each test run its own tag, wait for its email, and pull out the code or link:
import { extractCode, extractLink } from "@webhooks-cc/sdk";
const runId = `signup-${Date.now()}`;
await yourApp.signUp({ email: client.emails.address(endpoint.slug, runId) });
const email = await client.emails.waitFor(endpoint.slug, { tag: runId, timeout: "60s" });
console.log(email.email.subject, email.email.from[0]?.address);
const code = extractCode(email); // "482913", or null
const link = extractLink(email); // the confirm, verify, reset or sign-in link, or null
client.emails methods:
address(endpointOrSlug, tag?): the address, optionally tagged. Pass the endpoint object to use
the address the server reports; a slug uses the emailDomain client optionlist(slug, { tag, subject, from, to, limit, since }): emails newest first. limit (default 50)
is how many of the newest emails are fetched before the filters applyget(requestId): one email; throws NotFoundError for anything elselatest(slug, criteria): the newest matching email among the 100 newest, or nullwaitFor(slug, { ...criteria, timeout, pollInterval, since, match }): polls until a matching
email arrives (defaults: 60 s timeout, 1 s interval, looking back five minutes)waitForAll(slug, { count, ...options }): polls until count matching emails arrived, oldest
firstsendTest(slug, { tag? }): delivers a sample email with a six-digit code and a link. It counts as
one request and skips SMTP, so no sender checks run on ittoJson(email, { endpoint?, includeExtracts? }): the email.received JSON forwarding would postThe criteria are tag (exact and case-sensitive; null matches untagged mail), subject (a
substring or a RegExp), from and to (an address, compared case-insensitively, or a RegExp tested
against Name <address>). to also matches the address the email was delivered to.
extractFromEmail(email.email) returns every code and link found ({ codes, links }).
extractLink(email) returns the confirm, verify, reset or sign-in link when there is one, otherwise
the first link worth keeping; pass { actionOnly: true } to accept only an action link.
matchEmail(criteria) does the same filtering as a matcher for requests.waitFor() or
captureDuring(), and isEmailRequest(request) narrows a request to EmailRequest. Requests from
requests.subscribe() carry kind but not the parsed email; fetch it with
client.emails.get(request.id). requests.replay() throws for emails, requests.export() skips
them, and provider detection ignores them.
The @webhooks-cc/sdk/email entry point exports the email types, extractCode, extractLink,
extractFromEmail, htmlToText, buildEmailJson, emailAddress and isValidEmailTag. It has
no network code, so it fits in a browser bundle.
Forwarding sends what an endpoint captures to up to 10 destinations: your servers, Slack or Discord
channels, or tools such as Zapier and n8n. Each destination has its own kinds, conditions, format
(as received, signed JSON, your own JSON with {{ placeholders }}, or a chat message), throttle,
delay and signing secret, and is queued, retried and logged. Only the endpoint's owner can manage
it.
// Only paid invoices, as the JSON your tool wants.
const zap = await client.destinations.create(endpoint.slug, {
url: "https://hooks.zapier.com/hooks/catch/123/abc",
email: false,
filter: { conditions: [{ field: "body_path", op: "eq", path: "type", value: "invoice.paid" }] },
format: "template",
bodyTemplate: {
invoice: "{{ json.data.object.id }}",
amount: "{{ json.data.object.amount_paid }}",
},
});
// What it would send for the newest request, without sending it.
const preview = await client.destinations.preview(endpoint.slug, { destinationId: zap.id });
console.log(preview.filterPasses, preview.delivery?.body);
// Send one for real, and read its signing secret for your handler.
const test = await client.destinations.test(endpoint.slug, zap.id);
const secret = await client.destinations.secret(endpoint.slug, zap.id); // "whsec_..."
client.destinations also has list(slug, { summary }), get, update (only the fields you
pass), delete, reorder and rotateSecret. A Slack or Discord destination with
format: "chat" takes an optional messageTemplate, and throttleSeconds: 1 makes it a heads-up;
delaySeconds (up to an hour) makes any destination a callback that arrives later.
client.forwarding manages the endpoint's primary destination with configure(), secret(),
rotateSecret() and test(), and reads the log of all of them: deliveries(slug, { limit, status, destinationId, before }), summary(slug), requestDeliveries(requestId) (every try of one
request), redeliver(requestId, { destinationId }) and redeliverFailed(slug, { destinationId }).
In your handler, verify relayed requests with verifyRelayedRequest() (the
webhooks-cc-signature header), signed JSON with verifyForwardedRequest() or
verifyForwardedEmail(), and your own JSON with verifyForwardedJson(). For a forwarded email, verifyForwardedEmail() checks the Standard Webhooks
signature and that the timestamp is within five minutes, then returns the typed event. Pass the raw
body (string, Uint8Array/Buffer or ArrayBuffer) and the headers as a Fetch Headers, a plain
object such as Express's req.headers, or name and value pairs:
import { verifyForwardedEmail, WebhookVerificationError } from "@webhooks-cc/sdk";
export async function POST(request: Request) {
try {
const event = await verifyForwardedEmail(
await request.text(),
request.headers,
process.env.FORWARD_SECRET!
);
console.log(event.data.subject, event.data.codes);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) {
// error.code: missing_headers | timestamp_out_of_range | invalid_signature | invalid_payload
return new Response(error.message, { status: error.code === "invalid_payload" ? 400 : 401 });
}
throw error;
}
}
To test a handler on your machine without forwarding, post a captured email to it, signed with your secret:
const email = await client.emails.latest(endpoint.slug);
if (email) {
await client.sendTo("http://localhost:3000/hooks/email", {
provider: "standard-webhooks",
secret: process.env.FORWARD_SECRET!,
body: client.emails.toJson(email),
});
}
The SDK can generate provider-shaped webhook payloads (signed where the provider uses a shared secret or public test key flow) for:
stripegithubshopifytwilioslackpaddlelinearsendgridclerkdiscordvercelgitlabtypeformstandard-webhooksmetalemonsqueezycoinbase-commercerazorpaycalintercomtelegramsquarehubspotmailguncalendlymuxsentrybitbucketdocusignadyenpaypalplaidresendworkossendgrid, discord, paypal, and plaid templates are intentionally not shared-secret signed:
SendGrid uses IP allowlisting, Discord/PayPal signatures require provider-owned private keys, and
Plaid verification uses Plaid JWT/JWK lookup.
Inspect the static provider metadata:
const providers = client.templates.listProviders();
const stripe = client.templates.get("stripe");
console.log(providers);
console.log(stripe.signatureHeader, stripe.templates);
If you prefer a static export, import TEMPLATE_METADATA from @webhooks-cc/sdk.
Send a signed provider template through a hosted endpoint:
await client.endpoints.sendTemplate(endpoint.slug, {
provider: "slack",
template: "slash_command",
secret: process.env.SLACK_SIGNING_SECRET!,
});
Build or send a signed request directly to any URL:
const preview = await client.buildRequest("http://localhost:3001/webhooks", {
provider: "stripe",
template: "checkout.session.completed",
secret: "whsec_test_123",
});
await client.sendTo("http://localhost:3001/webhooks", {
provider: "github",
template: "push",
secret: "github_secret",
});
The SDK includes provider-specific verification helpers and a provider-agnostic verifySignature().
Provider-specific helpers such as verifyStripeSignature() and verifyDiscordSignature() are also exported.
Supported verification providers:
stripegithubshopifytwilioslackpaddlelinearclerkdiscordvercelgitlabtypeformstandard-webhooksmetalemonsqueezycoinbase-commercerazorpaycalintercomtelegramsquarehubspotmailguncalendlymuxsentrybitbucketdocusignadyenpaypalresendworkossendgrid and plaid are template-only for verification: SendGrid uses IP allowlisting, and Plaid
JWT/JWK verification requires Plaid API credentials.
import { isDiscordWebhook, verifySignature } from "@webhooks-cc/sdk";
if (isDiscordWebhook(request)) {
const result = await verifySignature(request, {
provider: "discord",
publicKey: process.env.DISCORD_PUBLIC_KEY!,
});
console.log(result.valid);
}
For Twilio, Square, and HubSpot, pass the original signed URL. HubSpot v3 also signs the HTTP method, so pass method too:
const result = await verifySignature(request, {
provider: "twilio",
secret: process.env.TWILIO_AUTH_TOKEN!,
url: "https://example.com/webhooks/twilio",
});
// HubSpot v3 signs `method + url + body + timestamp` and rejects stale timestamps
const hubspot = await verifySignature(request, {
provider: "hubspot",
secret: process.env.HUBSPOT_CLIENT_SECRET!,
url: "https://example.com/webhooks/hubspot",
method: "POST",
});
Mailgun is the exception with no signature header: it embeds signature.{timestamp,token,signature} in the request body, so verifyMailgunSignature reads the body directly and never throws on malformed input.
SendGrid uses IP allowlisting rather than cryptographic signature verification.
Request detection helpers are exported for every supported provider:
isStripeWebhook isGitHubWebhook isShopifyWebhook isSlackWebhook
isTwilioWebhook isPaddleWebhook isLinearWebhook isSendGridWebhook
isClerkWebhook isDiscordWebhook isVercelWebhook isGitLabWebhook
isTypeformWebhook isStandardWebhook isMetaWebhook isLemonSqueezyWebhook
isCoinbaseCommerceWebhook isRazorpayWebhook isCalWebhook isIntercomWebhook
isTelegramWebhook isSquareWebhook isHubSpotWebhook isMailgunWebhook
isCalendlyWebhook isMuxWebhook isSentryWebhook isBitbucketWebhook
isDocuSignWebhook isAdyenWebhook isPayPalWebhook isPlaidWebhook
isResendWebhook isWorkOSWebhook
Generate types for a handler from captured requests: TypeScript interfaces, Zod schemas, Pydantic
models, or JSON Schema. Pass several requests of the same event: a field missing from some samples
becomes optional, one that is sometimes null becomes nullable.
import { generateRequestSchema } from "@webhooks-cc/sdk";
const requests = await client.requests.list(endpoint.slug, { limit: 20 });
const { code, samples } = generateRequestSchema(requests, {
format: "zod", // "typescript" (default), "zod", "pydantic", or "json-schema"
name: "InvoicePaidEvent",
});
generatePayloadSchema(values, options) does the same from parsed values. Both work offline, and
@webhooks-cc/sdk/schema exports them without the client, for browser bundles.
Use matchers with waitFor() or waitForAll():
import {
matchAll,
matchBodySubset,
matchContentType,
matchHeader,
matchPath,
matchQueryParam,
} from "@webhooks-cc/sdk";
const request = await client.requests.waitFor(endpoint.slug, {
match: matchAll(
matchPath("/webhooks/stripe"),
matchHeader("stripe-signature"),
matchContentType("application/json"),
matchQueryParam("tenant", "acme"),
matchBodySubset({ type: "checkout.session.completed" })
),
});
matchAny(), matchBodyPath(), and matchJsonField() are available when you need looser matching.
matchEmail({ tag, subject, from, to }) matches captured emails; see Emails.
Parse request bodies and diff captures:
import { diffRequests, extractJsonField, parseBody, parseFormBody } from "@webhooks-cc/sdk";
const parsed = parseBody(request);
const form = parseFormBody(request);
const eventType = extractJsonField<string>(request, "type");
const diff = diffRequests(previousRequest, request, {
ignoreHeaders: ["date", "x-request-id"],
});
console.log(parsed, form, eventType, diff.matches);
@webhooks-cc/sdk/testing adds a small test-oriented layer:
withEndpoint()withEphemeralEndpoint()captureDuring()captureEmailDuring()assertRequest()import { matchHeader, WebhooksCC } from "@webhooks-cc/sdk";
import { assertRequest, captureDuring } from "@webhooks-cc/sdk/testing";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const [request] = await captureDuring(
client,
async (endpoint) => {
await yourApp.registerWebhook(endpoint.url!);
await yourApp.triggerCheckout();
},
{
expiresIn: "1h",
timeout: "20s",
match: matchHeader("stripe-signature"),
}
);
assertRequest(
request,
{
method: "POST",
bodyJson: { type: "checkout.session.completed" },
},
{ throwOnFailure: true }
);
captureEmailDuring() creates a temporary endpoint, runs your action with its email address, waits
for the email, and deletes the endpoint:
import { extractCode } from "@webhooks-cc/sdk";
import { captureEmailDuring } from "@webhooks-cc/sdk/testing";
const [email] = await captureEmailDuring(
client,
async (address) => {
await yourApp.signUp({ email: address });
},
{ subject: "Confirm your email", timeout: "30s" }
);
console.log(extractCode(email));
It takes the email criteria (tag, subject, from, to), count (default 1), timeout
(default 60 s), pollInterval, match, and the options of endpoints.create().
client.flow() composes the common test sequence into one chain: create endpoint, optionally set a mock, send a request, wait for capture, verify the signature, replay the request, and clean up.
const result = await client
.flow()
.createEndpoint({ expiresIn: "1h" })
.sendTemplate({
provider: "github",
template: "push",
secret: "github_secret",
})
.waitForCapture({ timeout: "15s" })
.verifySignature({
provider: "github",
secret: "github_secret",
})
.cleanup()
.run();
console.log(result.request?.id, result.verification?.valid, result.cleanedUp);
A key has exactly the team access of the account it belongs to. Endpoints shared with you through a subscribed team appear in endpoints.list() with fromTeam set and work with every request method by slug except requests.clear, which stays with the owner. The teams namespace manages the teams themselves:
const teams = await client.teams.list();
// [{ id, name, role, seats, requestsUsed, requestLimit, periodEnd, suspended, ... }]
// Only endpoints tied to one team (id or name), shared either way
const payments = await client.endpoints.list({ team: "Payments" });
// Owner-only: share an endpoint you own, by slug. Its requests then bill the team pool.
await client.teams.share(teams[0].id, "my-stripe");
await client.teams.unshare(teams[0].id, "my-stripe");
const { members, pendingInvites } = await client.teams.members(teams[0].id);
// Invites: sending one emails the address; accepting claims a paid seat.
await client.teams.invite(teams[0].id, "dev@example.com");
const invites = await client.teams.invites.list();
await client.teams.invites.accept(invites[0].id);
Check quota state from code:
const usage = await client.usage();
console.log(usage.used, usage.limit, usage.remaining, usage.plan);
Ask the client what it supports without making an API call:
const description = client.describe();
console.log(description.requests.waitForAll);
API failures throw typed errors:
WebhooksCCErrorUnauthorizedErrorNotFoundErrorTimeoutError (also thrown by requests.subscribe() when the idle watchdog trips without reconnect)RateLimitErrorverifyForwardedEmail() throws WebhookVerificationError, whose code is missing_headers,
timestamp_out_of_range, invalid_signature or invalid_payload.
ApiError is still exported as a legacy alias of WebhooksCCError.
MIT
FAQs
TypeScript SDK for webhooks.cc — create endpoints, capture requests, assert in tests
The npm package @webhooks-cc/sdk receives a total of 791 weekly downloads. As such, @webhooks-cc/sdk popularity was classified as not popular.
We found that @webhooks-cc/sdk demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Product
Socket Threat Intel now shows which malware campaigns were blocked or detected in your organization, with links to scans and Firewall events.

Security News
Fixes for eMudhra and Brotli are out. Getting them everywhere they’re needed means tracking down bundled libraries, runtimes, and certificate stores.

Threat Research
/Security News
Socket uncovered secret-stealing workflows that ran in LiteLLM’s GitHub repository 11 days before the supply chain attack on PyPI and Docker.