diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx
index 2ea1da76ce7c4a..fdd44af4a7ff40 100644
--- a/static/app/types/integrations.tsx
+++ b/static/app/types/integrations.tsx
@@ -256,6 +256,8 @@ export type SentryApp = {
id: number;
slug: string;
};
+ // Each entry is a "Header-Name: value" line. Saved values are masked by the API
+ webhookHeaders?: string[];
};
// Minimal Sentry App representation for use with avatars
diff --git a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx
index 9fbcf6fedc611e..178aa0b37a386c 100644
--- a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx
+++ b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx
@@ -124,6 +124,7 @@ describe('Sentry Application Details', () => {
verifyInstall: true,
isAlertable: true,
allowedOrigins: [],
+ webhookHeaders: [],
schema: {},
overview: '',
};
@@ -136,6 +137,38 @@ describe('Sentry Application Details', () => {
})
);
});
+
+ it('saves webhook headers', async () => {
+ render(, {
+ initialRouterConfig,
+ organization: OrganizationFixture({
+ features: ['sentry-apps-custom-webhook-headers'],
+ }),
+ });
+
+ await userEvent.type(screen.getByRole('textbox', {name: 'Name'}), 'Test App');
+ await userEvent.type(screen.getByRole('textbox', {name: 'Author'}), 'Sentry');
+ await userEvent.type(
+ screen.getByRole('textbox', {name: 'Webhook URL'}),
+ 'https://webhook.com'
+ );
+ await userEvent.type(
+ screen.getByRole('textbox', {name: 'Webhook Headers'}),
+ 'X-Example: value'
+ );
+
+ await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
+
+ expect(createAppRequest).toHaveBeenCalledWith(
+ '/sentry-apps/',
+ expect.objectContaining({
+ data: expect.objectContaining({
+ webhookHeaders: ['X-Example: value'],
+ }),
+ method: 'POST',
+ })
+ );
+ });
});
describe('Creating a new internal Sentry App', () => {
@@ -229,6 +262,25 @@ describe('Sentry Application Details', () => {
expect(screen.getByRole('textbox', {name: 'Client ID'})).toBeInTheDocument();
expect(screen.getByRole('textbox', {name: 'Client Secret'})).toBeInTheDocument();
});
+
+ it('prefills webhook headers from the app', async () => {
+ sentryApp.webhookHeaders = ['X-Example: value', 'Another-Header: thing'];
+ MockApiClient.addMockResponse({
+ url: `/sentry-apps/${sentryApp.slug}/`,
+ body: sentryApp,
+ });
+
+ render(, {
+ initialRouterConfig,
+ organization: OrganizationFixture({
+ features: ['sentry-apps-custom-webhook-headers'],
+ }),
+ });
+
+ expect(await screen.findByRole('textbox', {name: 'Webhook Headers'})).toHaveValue(
+ 'X-Example: value\nAnother-Header: thing'
+ );
+ });
});
describe('Renders for internal apps', () => {
diff --git a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx
index 7b2babc7d21b7d..1f69e26a038c17 100644
--- a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx
+++ b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx
@@ -86,6 +86,7 @@ const sentryAppFormSchema = z
name: z.string(),
author: z.string(),
webhookUrl: z.string(),
+ webhookHeaders: z.string(),
redirectUrl: z.string(),
verifyInstall: z.boolean(),
isAlertable: z.boolean(),
@@ -197,6 +198,7 @@ type SaveSentryAppPayload = {
author?: string | null;
overview?: string;
redirectUrl?: string;
+ webhookHeaders?: string[];
webhookUrl?: string;
};
@@ -514,6 +516,9 @@ function SentryApplicationForm({
schema: getSchemaFieldValue(app?.schema),
overview: app?.overview ?? '',
allowedOrigins: convertMultilineFieldValue(app?.allowedOrigins ?? []),
+ // Masked values (Header-Name: ***) round-trip safely: the backend preserves
+ // the stored value for any entry resubmitted with the mask sentinel.
+ webhookHeaders: convertMultilineFieldValue(app?.webhookHeaders ?? []),
organization: organization.slug,
isInternal,
scopes: app ? [...app.scopes] : [],
@@ -553,6 +558,7 @@ function SentryApplicationForm({
scopes: value.scopes,
events: value.events,
allowedOrigins: extractMultilineFields(value.allowedOrigins),
+ webhookHeaders: extractMultilineFields(value.webhookHeaders),
schema: value.schema.trim() === '' ? {} : JSON.parse(value.schema),
// The author parser doesn't allow_blank, so send null for empty
// (covers internal apps with no author).
@@ -689,6 +695,26 @@ function SentryApplicationForm({
)}
+ {organization.features.includes('sentry-apps-custom-webhook-headers') && (
+
+ {field => (
+
+ \nX-Custom-Header: value'}
+ />
+
+ )}
+
+ )}
+
{!isInternal && (
{field => (